Files

115 lines
5.2 KiB
TypeScript
Raw Permalink Normal View History

import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
import type { NextFunction, Request, Response } from 'express';
import type { CurrentUser, UserRole } from '../shared/types.js';
import { database } from './database.js';
export interface ApiKeyPrincipal {
id: number;
group_id: number | null;
project_id: number | null;
scope: 'platform' | 'project';
}
export type AuthRequest = Request & { authUser?: CurrentUser; apiKey?: ApiKeyPrincipal };
export function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
export function verifyPassword(password: string, stored: string): boolean {
const [salt, expected] = stored.split(':');
if (!salt || !expected) return false;
const actual = scryptSync(password, salt, 64);
const expectedBuffer = Buffer.from(expected, 'hex');
return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer);
}
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex');
return `${salt}:${scryptSync(password, salt, 64).toString('hex')}`;
}
export async function createSession(userId: number): Promise<string> {
const token = randomBytes(32).toString('base64url');
await database.execute('INSERT INTO sessions (user_id, token_hash, expires_at) VALUES (?, ?, ?)', [userId, sha256(token), new Date(Date.now() + 7 * 86400000).toISOString()]);
return token;
}
function readCookie(req: Request, name: string): string | undefined {
return req.headers.cookie?.split(';').map((item) => item.trim()).find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1);
}
export async function optionalAuth(req: AuthRequest, _res: Response, next: NextFunction) {
const bearer = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : undefined;
if (bearer?.startsWith('dd_')) {
const key = await database.one<ApiKeyPrincipal>(`
SELECT k.id, k.group_id, k.project_id, k.scope
FROM api_keys k
LEFT JOIN operation_groups g ON g.id = k.group_id
LEFT JOIN projects p ON p.id = k.project_id
WHERE k.key_hash = ? AND k.status = 'active'
AND (k.scope = 'platform' OR (g.status = 'active' AND p.status = 'active'))
`, [sha256(bearer)]);
if (key) {
req.apiKey = key;
await database.execute('UPDATE api_keys SET last_used_at = ? WHERE id = ?', [new Date().toISOString(), key.id]);
}
next();
return;
}
const token = bearer || readCookie(req, 'proofing_session');
if (token) {
const row = await database.one<Omit<CurrentUser, 'must_change_password'> & { must_change_password: boolean | number }>(`
SELECT u.id, u.group_id, g.name AS group_name, u.username, u.display_name, u.role, u.must_change_password
FROM sessions s JOIN users u ON u.id = s.user_id
LEFT JOIN operation_groups g ON g.id = u.group_id
WHERE s.token_hash = ? AND s.expires_at > ? AND u.status = 'active'
`, [sha256(token), new Date().toISOString()]);
if (row) req.authUser = { ...row, must_change_password: Boolean(row.must_change_password) };
}
next();
}
export function requireRole(...roles: UserRole[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.authUser) { res.status(401).json({ error: '请先登录运营账号' }); return; }
if (!roles.includes(req.authUser.role)) { res.status(403).json({ error: '当前账号没有执行此操作的权限' }); return; }
next();
};
}
export function requireWriter(req: AuthRequest, res: Response, next: NextFunction) {
if (req.authUser && ['platform_admin', 'group_admin', 'operator'].includes(req.authUser.role)) {
next();
return;
}
if (req.apiKey) {
next();
return;
}
res.status(401).json({ error: '请登录工作台账号或提供有效的 API Key' });
}
export async function canWriteProject(req: AuthRequest, projectId: number): Promise<boolean> {
if (req.authUser?.role === 'platform_admin') return true;
if (req.authUser) {
const project = await database.one<{ group_id: number | null }>('SELECT group_id FROM projects WHERE id = ?', [projectId]);
return Boolean(project && project.group_id === req.authUser.group_id);
}
if (req.apiKey?.scope === 'platform') return true;
return req.apiKey?.scope === 'project' && req.apiKey.project_id === projectId;
}
export async function audit(req: AuthRequest, action: string, entityType: string, entityId?: number, detail: Record<string, unknown> = {}) {
await database.execute('INSERT INTO audit_logs (group_id, user_id, action, entity_type, entity_id, detail) VALUES (?, ?, ?, ?, ?, ?)', [req.authUser?.group_id ?? req.apiKey?.group_id ?? null, req.authUser?.id ?? null, action, entityType, entityId ?? null, JSON.stringify(req.apiKey ? { ...detail, apiKeyId: req.apiKey.id } : detail)]);
}
export async function clearSession(token: string | undefined) {
if (token) await database.execute('DELETE FROM sessions WHERE token_hash = ?', [sha256(token)]);
}
export function sessionCookie(token: string): string {
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
return `proofing_session=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=604800${secure}`;
}