Agent/server/src/auth/auth.js

256 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 };