'use strict'; const argon2 = require('argon2'); const { randomUUID } = require('crypto'); const config = require('../config'); const db = require('../infra/db'); const redis = require('../infra/redis'); const rbac = require('../rbac/rbac'); const audit = require('../audit/audit'); const { ApiError, API_CODES } = require('../common/api-codes'); const { signEdDsa, verifyEdDsa } = require('./jwt-ed25519'); const MAX_FAILED_ATTEMPTS = 5; const LOCK_MINUTES = 15; function isStrongPassword(pwd) { return typeof pwd === 'string' && pwd.length >= 10 && /[a-z]/.test(pwd) && /[A-Z]/.test(pwd) && /\d/.test(pwd); } function signToken(payload, expiresIn) { return signEdDsa(payload, config.jwtPrivateKey, { expiresIn, kid: config.jwtKid, }); } function verifyToken(token) { return verifyEdDsa(token, config.jwtPublicKey); } async function issueTokens(userId, username, roles) { const jti = randomUUID(); const accessToken = signToken( { sub: userId, username, roles, type: 'access' }, `${config.accessTtlMin}m`, ); const refreshToken = signToken( { sub: userId, username, type: 'refresh', jti }, `${config.refreshTtlHours}h`, ); const ttlSec = config.refreshTtlHours * 3600; try { await redis.client.set(`refresh:${jti}`, String(userId), 'EX', ttlSec); await redis.client.sadd(`user_refresh:${userId}`, jti); await redis.client.expire(`user_refresh:${userId}`, ttlSec); } catch (e) { // Redis 不可用时 refresh 无法旋转失效,但 access 仍有效;记录告警 console.error('[auth] redis unavailable while issuing tokens:', e.message); } return { accessToken, refreshToken, expiresInSec: config.accessTtlMin * 60 }; } function verifyRefresh(token) { try { const payload = verifyToken(token); if (payload.type !== 'refresh') throw new Error('not refresh'); return payload; } catch (e) { throw new ApiError(API_CODES.UNAUTHENTICATED, 'refresh token 无效', 401); } } async function login(username, password, meta) { const rows = await db.query('SELECT * FROM users WHERE username = ? LIMIT 1', [username]); const user = rows[0]; const fail = async (reason) => { await audit.log({ username, clientType: 'ui', action: 'login_failed', module: 'users', payload: { reason }, ip: meta.ip, userAgent: meta.userAgent, }); throw new ApiError(API_CODES.UNAUTHENTICATED, '用户名或密码错误', 401); }; if (!user || user.deleted_at) return fail('user_not_found'); if (user.status === 'disabled') return fail('user_disabled'); if (user.locked_until && new Date(user.locked_until) > new Date()) { throw new ApiError(API_CODES.ACCOUNT_LOCKED, '账户已锁定,请稍后重试', 401); } let ok = false; try { ok = await argon2.verify(user.password_hash, password); } catch (e) { ok = false; // 占位/非法 hash 一律视为失败 } if (!ok) { const attempts = Number(user.failed_attempts) + 1; const lockedUntil = attempts >= MAX_FAILED_ATTEMPTS ? new Date(Date.now() + LOCK_MINUTES * 60000) : null; await db.execute('UPDATE users SET failed_attempts = ?, locked_until = ? WHERE id = ?', [ attempts, lockedUntil, user.id, ]); return fail(`bad_password(attempt=${attempts})`); } await db.execute( 'UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW(), last_login_ip = ? WHERE id = ?', [meta.ip || null, user.id], ); const roles = await rbac.getRoleCodes(Number(user.id)); const tokens = await issueTokens(Number(user.id), user.username, roles); const isDeveloper = config.developerUsers.has(user.username); const isOperations = isDeveloper || roles.includes('ops'); await audit.log({ userId: Number(user.id), username: user.username, roleCode: roles[0], clientType: 'ui', action: 'login', module: 'users', ip: meta.ip, userAgent: meta.userAgent, }); return { ...tokens, user: { id: Number(user.id), username: user.username, displayName: user.display_name, realName: user.real_name || '', roles, isDeveloper, isOperations, }, }; } async function refresh(refreshToken) { const payload = verifyRefresh(refreshToken); const key = `refresh:${payload.jti}`; // GETDEL 原子消费旧 jti,避免并发刷新请求同时复用同一 refresh token。 const owner = await redis.client.getdel(key); if (!owner || Number(owner) !== payload.sub) { throw new ApiError(API_CODES.UNAUTHENTICATED, 'refresh token 已失效', 401); } await redis.client.srem(`user_refresh:${payload.sub}`, payload.jti); const roles = await rbac.getRoleCodes(payload.sub); return issueTokens(payload.sub, payload.username, roles); } async function logout(user, refreshToken, meta) { if (refreshToken) { try { const payload = verifyRefresh(refreshToken); await redis.client.del(`refresh:${payload.jti}`); await redis.client.srem(`user_refresh:${payload.sub}`, payload.jti); } catch (e) { /* 过期/非法忽略 */ } } await audit.log({ userId: user.id, username: user.username, clientType: 'ui', action: 'logout', module: 'users', ip: meta.ip, userAgent: meta.userAgent, }); return { ok: true }; } async function changePassword(user, oldPassword, newPassword, meta) { const rows = await db.query('SELECT * FROM users WHERE id = ? LIMIT 1', [user.id]); const row = rows[0]; if (!row) throw new ApiError(API_CODES.NOT_FOUND, '用户不存在', 404); let ok = false; try { ok = await argon2.verify(row.password_hash, oldPassword); } catch (e) { ok = false; } if (!ok) throw new ApiError(API_CODES.UNAUTHENTICATED, '原密码错误', 401); if (!isStrongPassword(newPassword)) { throw new ApiError(API_CODES.WEAK_PASSWORD, '密码强度不足:至少 10 位,须含大小写字母与数字', 400); } const hash = await argon2.hash(newPassword, { type: argon2.argon2id }); await db.execute('UPDATE users SET password_hash = ?, pwd_changed_at = NOW() WHERE id = ?', [hash, row.id]); // 吊销该用户所有 refresh token(强制重新登录) try { const jtis = await redis.client.smembers(`user_refresh:${user.id}`); if (jtis.length > 0) await redis.client.del(...jtis.map((j) => `refresh:${j}`)); await redis.client.del(`user_refresh:${user.id}`); } catch (e) { /* ignore */ } await audit.log({ userId: user.id, username: user.username, clientType: 'ui', action: 'change_password', module: 'users', ip: meta.ip, userAgent: meta.userAgent, }); return { ok: true }; } async function profile(user) { const rows = await db.query( 'SELECT username, display_name, real_name FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1', [user.id], ); const row = rows[0]; if (!row) throw new ApiError(API_CODES.NOT_FOUND, '用户不存在', 404); const roles = await rbac.getRoleCodes(user.id); const permissions = await rbac.getPermissions(user.id); const isDeveloper = config.developerUsers.has(row.username); return { id: Number(user.id), username: row.username, displayName: row.display_name, realName: row.real_name || '', roles, permissions, isDeveloper, isOperations: isDeveloper || roles.includes('ops'), }; } /** * Express 中间件:校验 access JWT,挂 req.user = { id, username, roles }。 */ function requireAuth(req, res, next) { const header = req.headers.authorization; if (!header || !header.startsWith('Bearer ')) { return next(new ApiError(API_CODES.UNAUTHENTICATED, '未认证', 401)); } try { const payload = verifyToken(header.slice(7)); if (payload.type !== 'access') throw new Error('wrong token type'); req.user = { id: payload.sub, username: payload.username, roles: payload.roles || [] }; next(); } catch (e) { next(new ApiError(API_CODES.UNAUTHENTICATED, 'token 无效或已过期', 401)); } } module.exports = { login, refresh, logout, changePassword, profile, requireAuth, isStrongPassword };