feat(auth): add configurable password login with SSO default
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
# 登录方式与 Portainer 配置
|
||||||
|
|
||||||
|
不设置 `BI_AUTH_MODE` 时默认 `sso`,沿用业务系统 jumpToken 登录。
|
||||||
|
设置 `BI_AUTH_MODE=password` 时只开放固定密码登录,关闭 SSO 换票入口。
|
||||||
|
浏览器从 `/api/auth/config` 获取模式,密码不会打包到前端,不使用 VITE_ 密码变量。
|
||||||
|
|
||||||
|
## Portainer Stack
|
||||||
|
|
||||||
|
在现有 BI 服务的 `environment` 中合并以下配置,保留原镜像、端口、数据库等配置:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
BI_AUTH_MODE: ${BI_AUTH_MODE:-sso}
|
||||||
|
BI_AUTH_PASSWORD: ${BI_AUTH_PASSWORD:-}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:?必须配置独立的随机签名密钥}
|
||||||
|
DEV_BYPASS_AUTH: "0"
|
||||||
|
NODE_ENV: production
|
||||||
|
```
|
||||||
|
|
||||||
|
在 Stack 的 Environment variables 区域录入以下名称和值:
|
||||||
|
|
||||||
|
| 名称 | 固定密码模式 | SSO 模式 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| BI_AUTH_MODE | password | sso |
|
||||||
|
| BI_AUTH_PASSWORD | 独立随机密码,至少 16 字符 | 留空 |
|
||||||
|
| JWT_SECRET | 独立随机密钥,至少 32 字符 | 保留部署专用密钥 |
|
||||||
|
|
||||||
|
不要把真实密码提交进 Git。Portainer 中填写的 Stack 变量必须通过上面的 environment 映射才能进入容器。
|
||||||
|
更新 Stack 并重新创建容器;单纯 Restart 不会更新容器环境变量。
|
||||||
|
容器方式部署:Duplicate/Edit → Advanced container settings → Env,填写同样变量,再重新部署。
|
||||||
|
需要先构建包含本功能的新镜像;旧镜像不认识这些变量。
|
||||||
|
|
||||||
|
## 权限、安全和验证
|
||||||
|
|
||||||
|
- 固定密码身份是共享只读身份,具有全量看板数据读取范围及能源访问权限,无调度/反馈管理角色;服务端拒绝写入方法。
|
||||||
|
- 会话有效期 8 小时。更换密码、JWT_SECRET 或切换模式后,原密码会话失效。
|
||||||
|
- 每个服务实例 15 分钟最多 20 次密码验证请求;多副本需在网关配置共享限流。共享限流可能被恶意请求耗尽,建议只在内网或受控网络开放。
|
||||||
|
- 外网必须使用 HTTPS,避免明文传输密码和令牌。密码/环境变量对拥有容器管理权限的人可见。
|
||||||
|
- 密码少于 16 字符或签名密钥少于 32 字符时拒绝登录;无默认访问密码。不要沿用镜像中的旧默认 JWT_SECRET。
|
||||||
|
- DB_READ_ONLY=1 时密码登录仍可用,业务写入仍被禁止。
|
||||||
|
- 测试未登录访问、错误密码、正确密码、刷新恢复会话;切回 sso 后检查跳转登录。
|
||||||
|
- 开发免登录仅 SSO 开发模式生效,生产必须保持 DEV_BYPASS_AUTH=0。
|
||||||
|
|
||||||
|
Portainer 官方说明:https://docs.portainer.io/user/docker/containers/advanced
|
||||||
|
以及 https://docs.portainer.io/sts/user/docker/stacks/add
|
||||||
+3
-1
@@ -3,6 +3,7 @@ import { Shell } from "./components/Shell";
|
|||||||
import AuthProvider from "./auth/AuthProvider";
|
import AuthProvider from "./auth/AuthProvider";
|
||||||
import { useAuth } from "./auth/useAuth";
|
import { useAuth } from "./auth/useAuth";
|
||||||
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||||
|
import PasswordLogin from "./auth/PasswordLogin";
|
||||||
import { canAccessEnergy } from "./shared/auth/roles";
|
import { canAccessEnergy } from "./shared/auth/roles";
|
||||||
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
|
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
|
||||||
import { buildModules } from "./app/modules";
|
import { buildModules } from "./app/modules";
|
||||||
@@ -22,7 +23,7 @@ const HydrogenPrototypeBoard = lazy(
|
|||||||
normalizeBrowserPath();
|
normalizeBrowserPath();
|
||||||
|
|
||||||
function AuthGate() {
|
function AuthGate() {
|
||||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
const { isLoading, isAuthenticated, error, user, mode } = useAuth();
|
||||||
const [route, setRoute] = useState(readBrowserRoute);
|
const [route, setRoute] = useState(readBrowserRoute);
|
||||||
const { routeKey, pathSet } = route;
|
const { routeKey, pathSet } = route;
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ function AuthGate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
if (mode === 'password') return <PasswordLogin />;
|
||||||
return <UnauthorizedPage message={error || undefined} />;
|
return <UnauthorizedPage message={error || undefined} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { setTokenGetter } from './api-client';
|
|||||||
const AUTH_API = '/api/auth';
|
const AUTH_API = '/api/auth';
|
||||||
|
|
||||||
export default function AuthProvider({ children }: { children: ReactNode }) {
|
export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [mode, setMode] = useState<'sso' | 'password'>('sso');
|
||||||
const [state, setState] = useState<AuthState>({
|
const [state, setState] = useState<AuthState>({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
@@ -19,8 +20,6 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setTokenGetter(() => tokenRef.current);
|
setTokenGetter(() => tokenRef.current);
|
||||||
|
|
||||||
// 防止 StrictMode 双重调用(jumpToken 一次性使用)
|
// 防止 StrictMode 双重调用(jumpToken 一次性使用)
|
||||||
if (authStarted.current) return;
|
|
||||||
authStarted.current = true;
|
|
||||||
|
|
||||||
// 监听 401 事件
|
// 监听 401 事件
|
||||||
const onUnauthorized = () => {
|
const onUnauthorized = () => {
|
||||||
@@ -30,14 +29,25 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
window.addEventListener('auth:unauthorized', onUnauthorized);
|
window.addEventListener('auth:unauthorized', onUnauthorized);
|
||||||
|
|
||||||
authenticate();
|
if (!authStarted.current) { authStarted.current = true; authenticate(); }
|
||||||
|
|
||||||
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
|
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function authenticate() {
|
async function authenticate() {
|
||||||
|
let currentMode: 'sso' | 'password';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${AUTH_API}/config`, { cache: 'no-store' });
|
||||||
|
const config = await response.json();
|
||||||
|
if (!response.ok || !['sso', 'password'].includes(config.mode)) throw new Error();
|
||||||
|
currentMode = config.mode;
|
||||||
|
setMode(currentMode);
|
||||||
|
} catch {
|
||||||
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '无法获取验证配置,请刷新重试' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
|
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
|
||||||
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
if (currentMode === 'sso' && import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||||
setState({
|
setState({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
@@ -59,15 +69,15 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
tokenRef.current = savedToken;
|
tokenRef.current = savedToken;
|
||||||
// 验证 token 是否仍然有效(尝试请求 health)
|
// 验证 token 是否仍然有效(尝试请求 health)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/health', {
|
const res = await fetch(`${AUTH_API}/me`, {
|
||||||
headers: { Authorization: `Bearer ${savedToken}` },
|
headers: { Authorization: `Bearer ${savedToken}` },
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const savedUser = sessionStorage.getItem('bi_user');
|
const savedUser = await res.json();
|
||||||
setState({
|
setState({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
user: savedUser ? JSON.parse(savedUser) : null,
|
user: savedUser,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -75,6 +85,12 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
} catch { /* token 无效,继续流程 */ }
|
} catch { /* token 无效,继续流程 */ }
|
||||||
sessionStorage.removeItem('bi_jwt');
|
sessionStorage.removeItem('bi_jwt');
|
||||||
sessionStorage.removeItem('bi_user');
|
sessionStorage.removeItem('bi_user');
|
||||||
|
tokenRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentMode === 'password') {
|
||||||
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: null });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 从 URL 提取 jumpToken
|
// 2. 从 URL 提取 jumpToken
|
||||||
@@ -119,8 +135,18 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loginWithPassword(password: string) {
|
||||||
|
const response = await fetch(`${AUTH_API}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.token) throw new Error(data.message || '登录失败');
|
||||||
|
tokenRef.current = data.token;
|
||||||
|
sessionStorage.setItem('bi_jwt', data.token);
|
||||||
|
sessionStorage.setItem('bi_user', JSON.stringify(data.user));
|
||||||
|
setState({ isLoading: false, isAuthenticated: true, user: data.user, error: null });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={state}>
|
<AuthContext.Provider value={{ ...state, mode, loginWithPassword }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
|
||||||
|
export default function PasswordLogin() {
|
||||||
|
const { loginWithPassword, error: sessionError } = useAuth();
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
return <main className="min-h-screen flex items-center justify-center bg-slate-950 p-6">
|
||||||
|
<form className="w-full max-w-sm rounded-2xl bg-white p-7 shadow-xl" onSubmit={async event => {
|
||||||
|
event.preventDefault(); if (busy || !loginWithPassword) return;
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try { await loginWithPassword(password); } catch (e) { setError(e instanceof Error ? e.message : '登录失败,请重试'); }
|
||||||
|
finally { setBusy(false); setPassword(''); }
|
||||||
|
}}>
|
||||||
|
<p className="text-xs font-semibold text-blue-600">羚牛 · 能源 BI</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-bold text-slate-900">访问看板</h1>
|
||||||
|
<p className="mt-2 mb-6 text-sm text-slate-500">请输入管理员提供的访问密码。此入口仅提供只读访问。</p>
|
||||||
|
<label htmlFor="bi-password" className="text-sm font-medium text-slate-700">访问密码</label>
|
||||||
|
<input id="bi-password" type="password" autoComplete="current-password" required maxLength={1024} value={password} onChange={e => setPassword(e.target.value)} className="mt-2 w-full rounded-lg border border-slate-300 px-3 py-3 text-base focus:outline-blue-600" />
|
||||||
|
{error || sessionError ? <p role="alert" className="mt-3 text-sm text-red-600">{error || sessionError}</p> : null}
|
||||||
|
<button disabled={busy || !password} className="mt-5 min-h-11 w-full rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white disabled:opacity-50">{busy ? '正在验证…' : '进入看板'}</button>
|
||||||
|
</form>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
|
mode?: 'sso' | 'password';
|
||||||
|
loginWithPassword?: (password: string) => Promise<void>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import type { JwtPayload } from './types.js';
|
||||||
|
|
||||||
|
export function authMode() {
|
||||||
|
const mode = process.env.BI_AUTH_MODE || 'sso';
|
||||||
|
if (mode !== 'sso' && mode !== 'password') throw new Error('Invalid BI_AUTH_MODE');
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passwordConfig() {
|
||||||
|
const password = process.env.BI_AUTH_PASSWORD || '';
|
||||||
|
const secret = process.env.JWT_SECRET || '';
|
||||||
|
if (password.length < 16 || secret.length < 32) throw new Error('Password authentication is not configured securely');
|
||||||
|
return { password, key: createHmac('sha256', secret).update(`bi-password:${password}`).digest('hex') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyAuthToken(token: string): JwtPayload {
|
||||||
|
const passwordMode = authMode() === 'password';
|
||||||
|
const key = passwordMode ? passwordConfig().key : process.env.JWT_SECRET || 'ln-bi-default-secret';
|
||||||
|
const payload = jwt.verify(token, key, { algorithms: ['HS256'] }) as JwtPayload;
|
||||||
|
if (passwordMode ? payload.authMethod !== 'password' : payload.authMethod === 'password') throw new Error('Authentication mode changed');
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
@@ -3,14 +3,18 @@ import jwt from 'jsonwebtoken';
|
|||||||
import pool from '../db.js';
|
import pool from '../db.js';
|
||||||
import type { AuthUser, JwtPayload, PermissionLevel } from './types.js';
|
import type { AuthUser, JwtPayload, PermissionLevel } from './types.js';
|
||||||
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js';
|
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js';
|
||||||
|
import { authMode, verifyAuthToken } from './config.js';
|
||||||
|
import { passwordRouter } from './password.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
app.route('/', passwordRouter());
|
||||||
|
|
||||||
const EXTERNAL_API_BASE = process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com';
|
const EXTERNAL_API_BASE = process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com';
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
||||||
|
|
||||||
/** GET /api/auth/exchange?jumpToken=xxx — 一步完成:换取用户信息 + 签发 JWT */
|
/** GET /api/auth/exchange?jumpToken=xxx — 一步完成:换取用户信息 + 签发 JWT */
|
||||||
app.get('/exchange', async (c) => {
|
app.get('/exchange', async (c) => {
|
||||||
|
if (authMode() !== 'sso') return c.json({ message: '当前使用固定密码登录' }, 403);
|
||||||
const jumpToken = c.req.query('jumpToken');
|
const jumpToken = c.req.query('jumpToken');
|
||||||
if (!jumpToken) return c.json({ error: 'Missing jumpToken' }, 400);
|
if (!jumpToken) return c.json({ error: 'Missing jumpToken' }, 400);
|
||||||
|
|
||||||
@@ -90,7 +94,7 @@ app.get('/me', async (c) => {
|
|||||||
return c.json({ error: 'No token' }, 401);
|
return c.json({ error: 'No token' }, 401);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(authHeader.slice(7), JWT_SECRET) as JwtPayload;
|
const payload = verifyAuthToken(authHeader.slice(7));
|
||||||
return c.json(payload);
|
return c.json(payload);
|
||||||
} catch {
|
} catch {
|
||||||
return c.json({ error: 'Invalid token' }, 401);
|
return c.json({ error: 'Invalid token' }, 401);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Context, Next } from 'hono';
|
import type { Context, Next } from 'hono';
|
||||||
import jwt from 'jsonwebtoken';
|
import type { AuthUser } from './types.js';
|
||||||
import type { JwtPayload, AuthUser } from './types.js';
|
import { authMode, verifyAuthToken } from './config.js';
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
|
||||||
|
|
||||||
// 临时:跳过所有认证(保留完整逻辑便于快速恢复)
|
// 临时:跳过所有认证(保留完整逻辑便于快速恢复)
|
||||||
const BYPASS_AUTH = false;
|
const BYPASS_AUTH = false;
|
||||||
@@ -15,7 +14,7 @@ export async function authMiddleware(c: Context, next: Next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 本地开发免登录开关:.env 里设 DEV_BYPASS_AUTH=1 启用
|
// 本地开发免登录开关:.env 里设 DEV_BYPASS_AUTH=1 启用
|
||||||
if (process.env.DEV_BYPASS_AUTH === '1') {
|
if (process.env.NODE_ENV !== 'production' && process.env.DEV_BYPASS_AUTH === '1' && authMode() === 'sso') {
|
||||||
const devUser: AuthUser = {
|
const devUser: AuthUser = {
|
||||||
userId: 'dev-local',
|
userId: 'dev-local',
|
||||||
userName: '本地开发',
|
userName: '本地开发',
|
||||||
@@ -42,7 +41,10 @@ export async function authMiddleware(c: Context, next: Next) {
|
|||||||
const token = authHeader.slice(7);
|
const token = authHeader.slice(7);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, JWT_SECRET) as JwtPayload;
|
const payload = verifyAuthToken(token);
|
||||||
|
if (payload.authMethod === 'password' && !['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) {
|
||||||
|
return c.json({ error: 'Password account is read-only' }, 403);
|
||||||
|
}
|
||||||
const user: AuthUser = {
|
const user: AuthUser = {
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
userName: payload.userName,
|
userName: payload.userName,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { passwordRouter } from './password.js';
|
||||||
|
import { authMiddleware } from './middleware.js';
|
||||||
|
import { readOnlyMiddleware } from '../read-only-middleware.js';
|
||||||
|
import { verifyAuthToken } from './config.js';
|
||||||
|
|
||||||
|
test('固定密码模式默认关闭、失败限流、只读权限及换密失效', async () => {
|
||||||
|
const previous = { ...process.env };
|
||||||
|
try {
|
||||||
|
delete process.env.BI_AUTH_MODE;
|
||||||
|
process.env.JWT_SECRET = 'test-signing-secret-with-at-least-32-characters';
|
||||||
|
process.env.BI_AUTH_PASSWORD = 'test-password-only-123456';
|
||||||
|
process.env.DEV_BYPASS_AUTH = '0';
|
||||||
|
const app = new Hono();
|
||||||
|
app.use('/api/*', readOnlyMiddleware);
|
||||||
|
app.route('/api/auth', passwordRouter());
|
||||||
|
app.use('/api/*', authMiddleware);
|
||||||
|
app.get('/api/data', c => c.json({ ok: true }));
|
||||||
|
app.post('/api/data', c => c.json({ ok: true }));
|
||||||
|
const login = (password: unknown) => app.request('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
|
||||||
|
assert.deepEqual(await (await app.request('/api/auth/config')).json(), { mode: 'sso' });
|
||||||
|
assert.equal((await login(process.env.BI_AUTH_PASSWORD)).status, 403);
|
||||||
|
const ssoToken = jwt.sign({ userId: 'sso-user', roles: [] }, process.env.JWT_SECRET);
|
||||||
|
assert.equal(verifyAuthToken(ssoToken).userId, 'sso-user');
|
||||||
|
process.env.BI_AUTH_MODE = 'password';
|
||||||
|
assert.throws(() => verifyAuthToken(ssoToken));
|
||||||
|
assert.equal((await login('wrong')).status, 401);
|
||||||
|
process.env.DB_READ_ONLY = '1';
|
||||||
|
const response = await login(process.env.BI_AUTH_PASSWORD);
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const { token, user } = await response.json();
|
||||||
|
assert.deepEqual(user.roles, ['BI-LEADER-ENERGY']);
|
||||||
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
|
assert.equal((await app.request('/api/data', { headers })).status, 200);
|
||||||
|
const payload = verifyAuthToken(token);
|
||||||
|
assert.equal(Number(payload.exp) - Number(payload.iat), 8 * 60 * 60);
|
||||||
|
process.env.DB_READ_ONLY = '0';
|
||||||
|
assert.equal((await app.request('/api/data', { method: 'POST', headers })).status, 403);
|
||||||
|
assert.equal((await app.request('/api/data')).status, 401);
|
||||||
|
assert.throws(() => verifyAuthToken(jwt.sign({ authMethod: 'password' }, 'wrong-key')));
|
||||||
|
process.env.BI_AUTH_PASSWORD = 'a-different-password-123456';
|
||||||
|
assert.equal((await app.request('/api/data', { headers })).status, 401);
|
||||||
|
process.env.BI_AUTH_MODE = 'sso';
|
||||||
|
assert.equal((await app.request('/api/data', { headers })).status, 401);
|
||||||
|
process.env.BI_AUTH_MODE = 'password';
|
||||||
|
delete process.env.BI_AUTH_PASSWORD;
|
||||||
|
assert.equal((await login('')).status, 503);
|
||||||
|
process.env.BI_AUTH_PASSWORD = 'a-different-password-123456';
|
||||||
|
for (let i = 0; i < 20; i++) await login('wrong');
|
||||||
|
assert.equal((await login(process.env.BI_AUTH_PASSWORD)).status, 429);
|
||||||
|
} finally {
|
||||||
|
for (const key of ['BI_AUTH_MODE', 'BI_AUTH_PASSWORD', 'JWT_SECRET', 'DEV_BYPASS_AUTH', 'DB_READ_ONLY']) {
|
||||||
|
if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { bodyLimit } from 'hono/body-limit';
|
||||||
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { authMode, passwordConfig } from './config.js';
|
||||||
|
|
||||||
|
export function passwordRouter() {
|
||||||
|
const app = new Hono();
|
||||||
|
app.use('/password', bodyLimit({ maxSize: 4096 }));
|
||||||
|
// One shared account: a bounded instance-wide budget cannot be bypassed by spoofing proxy headers.
|
||||||
|
let attempts = 0;
|
||||||
|
let resetAt = 0;
|
||||||
|
app.get('/config', c => {
|
||||||
|
c.header('Cache-Control', 'no-store');
|
||||||
|
try { return c.json({ mode: authMode() }); }
|
||||||
|
catch { return c.json({ message: '验证方式配置错误,请联系管理员' }, 503); }
|
||||||
|
});
|
||||||
|
app.post('/password', async c => {
|
||||||
|
c.header('Cache-Control', 'no-store');
|
||||||
|
try {
|
||||||
|
if (authMode() !== 'password') return c.json({ message: '未启用固定密码登录' }, 403);
|
||||||
|
const config = passwordConfig();
|
||||||
|
if (Date.now() >= resetAt) { attempts = 0; resetAt = Date.now() + 15 * 60_000; }
|
||||||
|
if (attempts >= 20) {
|
||||||
|
c.header('Retry-After', String(Math.ceil((resetAt - Date.now()) / 1000)));
|
||||||
|
return c.json({ message: '尝试过于频繁,请稍后再试' }, 429);
|
||||||
|
}
|
||||||
|
attempts++;
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
if (typeof body?.password !== 'string' || body.password.length > 1024) return c.json({ message: '密码错误' }, 401);
|
||||||
|
const digest = (s: string) => createHash('sha256').update(s).digest();
|
||||||
|
if (!timingSafeEqual(digest(body.password), digest(config.password))) return c.json({ message: '密码错误' }, 401);
|
||||||
|
const user = { userId: 'bi-password-viewer', userName: '看板访客', loginName: 'bi-password-viewer', depCode: '', depName: '', permissionLevel: 'full' as const, roles: ['BI-LEADER-ENERGY'], authMethod: 'password' as const };
|
||||||
|
return c.json({ token: jwt.sign(user, config.key, { expiresIn: '8h', algorithm: 'HS256' }), user });
|
||||||
|
} catch { return c.json({ message: '密码登录配置不可用,请联系管理员' }, 503); }
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export interface AuthUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
|
authMethod?: 'sso' | 'password';
|
||||||
userId: string;
|
userId: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
loginName: string;
|
loginName: string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { MiddlewareHandler } from 'hono';
|
|||||||
/** Guard preview write endpoints before auth/route handlers can perform work. */
|
/** Guard preview write endpoints before auth/route handlers can perform work. */
|
||||||
export const readOnlyMiddleware: MiddlewareHandler = async (context, next) => {
|
export const readOnlyMiddleware: MiddlewareHandler = async (context, next) => {
|
||||||
if (process.env.DB_READ_ONLY === '1'
|
if (process.env.DB_READ_ONLY === '1'
|
||||||
|
&& context.req.path !== '/api/auth/password'
|
||||||
&& !['GET', 'HEAD', 'OPTIONS'].includes(context.req.method)) {
|
&& !['GET', 'HEAD', 'OPTIONS'].includes(context.req.method)) {
|
||||||
return context.json({ error: '当前为只读预览环境,不允许写入操作' }, 403);
|
return context.json({ error: '当前为只读预览环境,不允许写入操作' }, 403);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user