From 0dc7e89c1dfd7485a14f3a13b57ef3482d8caf83 Mon Sep 17 00:00:00 2001 From: kfluous Date: Sat, 5 Sep 2026 18:22:41 +0800 Subject: [PATCH] feat(auth): add configurable password login with SSO default Co-authored-by: HiFox Agent --- docs/auth-portainer.md | 45 +++++++++++++++++++++++ src/App.tsx | 4 +- src/auth/AuthProvider.tsx | 42 +++++++++++++++++---- src/auth/PasswordLogin.tsx | 25 +++++++++++++ src/auth/useAuth.ts | 2 + src/server/auth/config.ts | 24 ++++++++++++ src/server/auth/login.ts | 6 ++- src/server/auth/middleware.ts | 12 +++--- src/server/auth/password.test.ts | 59 ++++++++++++++++++++++++++++++ src/server/auth/password.ts | 38 +++++++++++++++++++ src/server/auth/types.ts | 1 + src/server/read-only-middleware.ts | 1 + 12 files changed, 244 insertions(+), 15 deletions(-) create mode 100644 docs/auth-portainer.md create mode 100644 src/auth/PasswordLogin.tsx create mode 100644 src/server/auth/config.ts create mode 100644 src/server/auth/password.test.ts create mode 100644 src/server/auth/password.ts diff --git a/docs/auth-portainer.md b/docs/auth-portainer.md new file mode 100644 index 0000000..f764b38 --- /dev/null +++ b/docs/auth-portainer.md @@ -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 diff --git a/src/App.tsx b/src/App.tsx index 23aa34c..c8ae409 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { Shell } from "./components/Shell"; import AuthProvider from "./auth/AuthProvider"; import { useAuth } from "./auth/useAuth"; import UnauthorizedPage from "./auth/UnauthorizedPage"; +import PasswordLogin from "./auth/PasswordLogin"; import { canAccessEnergy } from "./shared/auth/roles"; import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface"; import { buildModules } from "./app/modules"; @@ -22,7 +23,7 @@ const HydrogenPrototypeBoard = lazy( normalizeBrowserPath(); function AuthGate() { - const { isLoading, isAuthenticated, error, user } = useAuth(); + const { isLoading, isAuthenticated, error, user, mode } = useAuth(); const [route, setRoute] = useState(readBrowserRoute); const { routeKey, pathSet } = route; @@ -71,6 +72,7 @@ function AuthGate() { } if (!isAuthenticated) { + if (mode === 'password') return ; return ; } diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx index 9da7247..7a0b561 100644 --- a/src/auth/AuthProvider.tsx +++ b/src/auth/AuthProvider.tsx @@ -5,6 +5,7 @@ import { setTokenGetter } from './api-client'; const AUTH_API = '/api/auth'; export default function AuthProvider({ children }: { children: ReactNode }) { + const [mode, setMode] = useState<'sso' | 'password'>('sso'); const [state, setState] = useState({ isLoading: true, isAuthenticated: false, @@ -19,8 +20,6 @@ export default function AuthProvider({ children }: { children: ReactNode }) { setTokenGetter(() => tokenRef.current); // 防止 StrictMode 双重调用(jumpToken 一次性使用) - if (authStarted.current) return; - authStarted.current = true; // 监听 401 事件 const onUnauthorized = () => { @@ -30,14 +29,25 @@ export default function AuthProvider({ children }: { children: ReactNode }) { }; window.addEventListener('auth:unauthorized', onUnauthorized); - authenticate(); + if (!authStarted.current) { authStarted.current = true; authenticate(); } return () => window.removeEventListener('auth:unauthorized', onUnauthorized); }, []); 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 生效 - 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({ isLoading: false, isAuthenticated: true, @@ -59,15 +69,15 @@ export default function AuthProvider({ children }: { children: ReactNode }) { tokenRef.current = savedToken; // 验证 token 是否仍然有效(尝试请求 health) try { - const res = await fetch('/api/health', { + const res = await fetch(`${AUTH_API}/me`, { headers: { Authorization: `Bearer ${savedToken}` }, }); if (res.ok) { - const savedUser = sessionStorage.getItem('bi_user'); + const savedUser = await res.json(); setState({ isLoading: false, isAuthenticated: true, - user: savedUser ? JSON.parse(savedUser) : null, + user: savedUser, error: null, }); return; @@ -75,6 +85,12 @@ export default function AuthProvider({ children }: { children: ReactNode }) { } catch { /* token 无效,继续流程 */ } sessionStorage.removeItem('bi_jwt'); sessionStorage.removeItem('bi_user'); + tokenRef.current = null; + } + + if (currentMode === 'password') { + setState({ isLoading: false, isAuthenticated: false, user: null, error: null }); + return; } // 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 ( - + {children} ); diff --git a/src/auth/PasswordLogin.tsx b/src/auth/PasswordLogin.tsx new file mode 100644 index 0000000..984cda6 --- /dev/null +++ b/src/auth/PasswordLogin.tsx @@ -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
+
{ + 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(''); } + }}> +

羚牛 · 能源 BI

+

访问看板

+

请输入管理员提供的访问密码。此入口仅提供只读访问。

+ + 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 ?

{error || sessionError}

: null} + +
+
; +} diff --git a/src/auth/useAuth.ts b/src/auth/useAuth.ts index c869e3a..9c97471 100644 --- a/src/auth/useAuth.ts +++ b/src/auth/useAuth.ts @@ -1,6 +1,8 @@ import { createContext, useContext } from 'react'; export interface AuthState { + mode?: 'sso' | 'password'; + loginWithPassword?: (password: string) => Promise; isLoading: boolean; isAuthenticated: boolean; user: { diff --git a/src/server/auth/config.ts b/src/server/auth/config.ts new file mode 100644 index 0000000..f5e7253 --- /dev/null +++ b/src/server/auth/config.ts @@ -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; +} diff --git a/src/server/auth/login.ts b/src/server/auth/login.ts index cfa608a..376f7eb 100644 --- a/src/server/auth/login.ts +++ b/src/server/auth/login.ts @@ -3,14 +3,18 @@ import jwt from 'jsonwebtoken'; import pool from '../db.js'; import type { AuthUser, JwtPayload, PermissionLevel } 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(); +app.route('/', passwordRouter()); const EXTERNAL_API_BASE = process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com'; const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret'; /** GET /api/auth/exchange?jumpToken=xxx — 一步完成:换取用户信息 + 签发 JWT */ app.get('/exchange', async (c) => { + if (authMode() !== 'sso') return c.json({ message: '当前使用固定密码登录' }, 403); const jumpToken = c.req.query('jumpToken'); 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); } try { - const payload = jwt.verify(authHeader.slice(7), JWT_SECRET) as JwtPayload; + const payload = verifyAuthToken(authHeader.slice(7)); return c.json(payload); } catch { return c.json({ error: 'Invalid token' }, 401); diff --git a/src/server/auth/middleware.ts b/src/server/auth/middleware.ts index 898fad9..d1fb3a8 100644 --- a/src/server/auth/middleware.ts +++ b/src/server/auth/middleware.ts @@ -1,8 +1,7 @@ import type { Context, Next } from 'hono'; -import jwt from 'jsonwebtoken'; -import type { JwtPayload, AuthUser } from './types.js'; +import type { 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; @@ -15,7 +14,7 @@ export async function authMiddleware(c: Context, next: Next) { } // 本地开发免登录开关:.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 = { userId: 'dev-local', userName: '本地开发', @@ -42,7 +41,10 @@ export async function authMiddleware(c: Context, next: Next) { const token = authHeader.slice(7); 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 = { userId: payload.userId, userName: payload.userName, diff --git a/src/server/auth/password.test.ts b/src/server/auth/password.test.ts new file mode 100644 index 0000000..31b567f --- /dev/null +++ b/src/server/auth/password.test.ts @@ -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]; + } + } +}); diff --git a/src/server/auth/password.ts b/src/server/auth/password.ts new file mode 100644 index 0000000..561006e --- /dev/null +++ b/src/server/auth/password.ts @@ -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; +} diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index 20112bb..23c269c 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -11,6 +11,7 @@ export interface AuthUser { } export interface JwtPayload { + authMethod?: 'sso' | 'password'; userId: string; userName: string; loginName: string; diff --git a/src/server/read-only-middleware.ts b/src/server/read-only-middleware.ts index 346372f..647ef11 100644 --- a/src/server/read-only-middleware.ts +++ b/src/server/read-only-middleware.ts @@ -3,6 +3,7 @@ import type { MiddlewareHandler } from 'hono'; /** Guard preview write endpoints before auth/route handlers can perform work. */ export const readOnlyMiddleware: MiddlewareHandler = async (context, next) => { if (process.env.DB_READ_ONLY === '1' + && context.req.path !== '/api/auth/password' && !['GET', 'HEAD', 'OPTIONS'].includes(context.req.method)) { return context.json({ error: '当前为只读预览环境,不允许写入操作' }, 403); }