feat(auth): add configurable password login with SSO default
ci/woodpecker/push/woodpecker Pipeline was successful

Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
kfluous
2026-09-05 18:22:41 +08:00
co-authored by HiFox Agent
parent 4430fb75f1
commit 0dc7e89c1d
12 changed files with 244 additions and 15 deletions
+45
View File
@@ -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
View File
@@ -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} />;
} }
+34 -8
View File
@@ -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>
); );
+25
View File
@@ -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>;
}
+2
View File
@@ -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: {
+24
View File
@@ -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;
}
+5 -1
View File
@@ -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);
+7 -5
View File
@@ -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,
+59
View File
@@ -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];
}
}
});
+38
View File
@@ -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;
}
+1
View File
@@ -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;
+1
View File
@@ -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);
} }