256 lines
8.0 KiB
JavaScript
256 lines
8.0 KiB
JavaScript
'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 };
|