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
+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 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);
+7 -5
View File
@@ -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,
+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 {
authMethod?: 'sso' | 'password';
userId: string;
userName: 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. */
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);
}