This commit is contained in:
Baobhan Sith
2025-04-15 18:59:56 +08:00
parent 7649a7b69d
commit c026a42d06
43 changed files with 3479 additions and 169 deletions
+79 -4
View File
@@ -5,9 +5,14 @@ import sqlite3, { RunResult } from 'sqlite3';
import speakeasy from 'speakeasy';
import qrcode from 'qrcode';
import { PasskeyService } from '../services/passkey.service'; // 导入 PasskeyService
import { NotificationService } from '../services/notification.service'; // 导入 NotificationService
import { AuditLogService } from '../services/audit.service'; // 导入 AuditLogService
import { ipBlacklistService } from '../services/ip-blacklist.service'; // 导入 IP 黑名单服务
const db = getDb();
const passkeyService = new PasskeyService(); // 实例化 PasskeyService
const notificationService = new NotificationService(); // 实例化 NotificationService
const auditLogService = new AuditLogService(); // 实例化 AuditLogService
// 用户数据结构占位符 (理想情况下应定义在共享的 types 文件中)
interface User {
@@ -26,6 +31,7 @@ declare module 'express-session' {
tempTwoFactorSecret?: string;
requiresTwoFactor?: boolean;
currentChallenge?: string; // 用于存储 Passkey 操作的挑战
rememberMe?: boolean; // 新增:临时存储“记住我”选项
}
}
@@ -34,7 +40,8 @@ declare module 'express-session' {
* 处理用户登录请求 (POST /api/v1/auth/login)
*/
export const login = async (req: Request, res: Response): Promise<void> => {
const { username, password } = req.body;
// 从请求体中解构 username, password 和可选的 rememberMe
const { username, password, rememberMe } = req.body;
if (!username || !password) {
res.status(400).json({ message: '用户名和密码不能为空。' });
@@ -55,6 +62,13 @@ export const login = async (req: Request, res: Response): Promise<void> => {
if (!user) {
console.log(`登录尝试失败: 用户未找到 - ${username}`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录失败尝试
ipBlacklistService.recordFailedAttempt(clientIp);
// 记录审计日志 (添加 IP)
auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'User not found', ip: clientIp });
// 发送登录失败通知
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'User not found', ip: clientIp });
res.status(401).json({ message: '无效的凭据。' });
return;
}
@@ -63,6 +77,13 @@ export const login = async (req: Request, res: Response): Promise<void> => {
if (!isMatch) {
console.log(`登录尝试失败: 密码错误 - ${username}`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录失败尝试
ipBlacklistService.recordFailedAttempt(clientIp);
// 记录审计日志 (添加 IP)
auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp });
// 发送登录失败通知
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp });
res.status(401).json({ message: '无效的凭据。' });
return;
}
@@ -73,13 +94,30 @@ export const login = async (req: Request, res: Response): Promise<void> => {
// 不设置完整 session,只标记需要 2FA
req.session.userId = user.id; // 临时存储 userId 以便 2FA 验证
req.session.requiresTwoFactor = true;
req.session.rememberMe = rememberMe; // 临时存储 rememberMe 状态
res.status(200).json({ message: '需要进行两步验证。', requiresTwoFactor: true });
} else {
// --- 认证成功 (未启用 2FA) ---
console.log(`登录成功 (无 2FA): ${username}`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 重置失败尝试次数
ipBlacklistService.resetAttempts(clientIp);
// 记录审计日志 (添加 IP)
auditLogService.logAction('LOGIN_SUCCESS', { userId: user.id, username, ip: clientIp });
req.session.userId = user.id;
req.session.username = user.username;
req.session.requiresTwoFactor = false; // 明确标记不需要 2FA
// 根据 rememberMe 设置 cookie maxAge
if (rememberMe) {
// 如果记住我,使用默认的 maxAge (在 index.ts 中设置,通常是 7 天)
// 如果需要强制覆盖为 7 天,取消下一行注释
// req.session.cookie.maxAge = 1000 * 60 * 60 * 24 * 7;
} else {
// 如果不记住我,设置为会话 cookie (浏览器关闭时过期)
req.session.cookie.maxAge = undefined; // 使用 undefined 表示会话 cookie
}
res.status(200).json({
message: '登录成功。',
user: { id: user.id, username: user.username }
@@ -183,15 +221,37 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise<void>
if (verified) {
console.log(`用户 ${user.username} 2FA 验证成功。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 重置失败尝试次数
ipBlacklistService.resetAttempts(clientIp);
// 记录审计日志 (2FA 成功也算登录成功) (添加 IP)
auditLogService.logAction('LOGIN_SUCCESS', { userId: user.id, username: user.username, ip: clientIp, twoFactor: true });
// 验证成功,建立完整会话
req.session.username = user.username;
req.session.requiresTwoFactor = false; // 标记 2FA 已完成
// 根据之前存储在 session 中的 rememberMe 设置 cookie maxAge
if (req.session.rememberMe) {
// 如果记住我,使用默认的 maxAge
// req.session.cookie.maxAge = 1000 * 60 * 60 * 24 * 7;
} else {
// 如果不记住我,设置为会话 cookie
req.session.cookie.maxAge = undefined;
}
// 清除临时的 rememberMe 状态
delete req.session.rememberMe;
res.status(200).json({
message: '登录成功。',
user: { id: user.id, username: user.username }
});
} else {
console.log(`用户 ${user.username} 2FA 验证失败: 验证码错误。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录失败尝试
ipBlacklistService.recordFailedAttempt(clientIp);
// 记录审计日志 (添加 IP)
auditLogService.logAction('LOGIN_FAILURE', { userId: user.id, username: user.username, reason: 'Invalid 2FA token', ip: clientIp });
res.status(401).json({ message: '验证码无效。' });
}
@@ -268,9 +328,12 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
}
if (this.changes === 0) {
console.error(`修改密码错误: 更新影响行数为 0 - 用户 ID ${userId}`);
return rejectUpdate(new Error('未找到要更新的用户'));
return rejectUpdate(new Error('未找到要更新的用户'));
}
console.log(`用户 ${userId} 密码已成功修改。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录审计日志 (添加 IP)
auditLogService.logAction('PASSWORD_CHANGED', { userId, ip: clientIp });
resolveUpdate();
});
stmt.finalize();
@@ -405,7 +468,13 @@ export const verifyPasskeyRegistration = async (req: Request, res: Response): Pr
name
);
if (verification.verified) {
// Check if verification was successful and registrationInfo is present
if (verification.verified && verification.registrationInfo) {
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录审计日志 (添加 IP)
// Use type assertion 'as any' to bypass persistent TS error for now
const regInfo: any = verification.registrationInfo;
auditLogService.logAction('PASSKEY_REGISTERED', { userId, passkeyId: regInfo.credentialID, name, ip: clientIp });
res.status(201).json({ message: 'Passkey 注册成功!', verified: true });
} else {
console.error(`用户 ${userId} Passkey 注册验证失败:`, verification);
@@ -463,9 +532,12 @@ export const verifyAndActivate2FA = async (req: Request, res: Response): Promise
}
if (this.changes === 0) {
console.error(`激活 2FA 错误: 更新影响行数为 0 - 用户 ID ${userId}`);
return rejectUpdate(new Error('未找到要更新的用户'));
return rejectUpdate(new Error('未找到要更新的用户'));
}
console.log(`用户 ${userId} 已成功激活两步验证。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录审计日志 (添加 IP)
auditLogService.logAction('2FA_ENABLED', { userId, ip: clientIp });
resolveUpdate();
});
stmt.finalize();
@@ -535,6 +607,9 @@ export const disable2FA = async (req: Request, res: Response): Promise<void> =>
return rejectUpdate(new Error('未找到要更新的用户'));
}
console.log(`用户 ${userId} 已成功禁用两步验证。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录审计日志 (添加 IP)
auditLogService.logAction('2FA_DISABLED', { userId, ip: clientIp });
resolveUpdate();
});
stmt.finalize();
+6 -4
View File
@@ -11,17 +11,19 @@ import {
verifyPasskeyRegistration // 导入 Passkey 方法
} from './auth.controller';
import { isAuthenticated } from './auth.middleware';
import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware'; // 导入 IP 黑名单检查中间件
const router = Router();
// POST /api/v1/auth/login - 用户登录接口
router.post('/login', login);
// POST /api/v1/auth/login - 用户登录接口 (添加黑名单检查)
router.post('/login', ipBlacklistCheckMiddleware, login);
// PUT /api/v1/auth/password - 修改密码接口 (需要认证)
router.put('/password', isAuthenticated, changePassword);
// POST /api/v1/auth/login/2fa - 登录时的 2FA 验证接口 (不需要单独的 isAuthenticated,依赖 login 接口设置的临时 session)
router.post('/login/2fa', verifyLogin2FA);
// POST /api/v1/auth/login/2fa - 登录时的 2FA 验证接口 (添加黑名单检查)
// (不需要单独的 isAuthenticated,依赖 login 接口设置的临时 session)
router.post('/login/2fa', ipBlacklistCheckMiddleware, verifyLogin2FA);
// --- 2FA 管理接口 (都需要认证) ---
// POST /api/v1/auth/2fa/setup - 开始 2FA 设置,生成密钥和二维码
@@ -0,0 +1,37 @@
import { Request, Response, NextFunction } from 'express';
import { ipBlacklistService } from '../services/ip-blacklist.service';
/**
* IP 黑名单检查中间件
* 在处理登录相关请求前,检查来源 IP 是否在黑名单中且处于封禁期。
*/
export const ipBlacklistCheckMiddleware = async (req: Request, res: Response, next: NextFunction) => {
// 获取客户端 IP (与 auth.controller 一致)
const clientIp = req.ip || req.socket?.remoteAddress;
if (!clientIp) {
// 如果无法获取 IP,为安全起见,阻止请求
console.warn('[IP Blacklist Check] 无法获取请求 IP 地址,已拒绝访问。');
res.status(403).json({ message: '禁止访问:无法识别来源 IP。' });
return; // 显式返回 void
}
try {
const isBlocked = await ipBlacklistService.isBlocked(clientIp);
if (isBlocked) {
console.warn(`[IP Blacklist Check] 已阻止来自被封禁 IP ${clientIp} 的访问。`);
// 可以返回更通用的错误信息,避免泄露封禁状态
res.status(403).json({ message: '访问被拒绝。' });
// 或者返回更具体的错误
// res.status(429).json({ message: '尝试次数过多,请稍后再试。' });
return; // 显式返回 void
}
// IP 未被封禁,继续处理请求
next();
} catch (error) {
console.error(`[IP Blacklist Check] 检查 IP ${clientIp} 时发生错误:`, error);
// 中间件执行出错,为安全起见,阻止请求
res.status(500).json({ message: '服务器内部错误 (IP 黑名单检查失败)。' });
return; // 显式返回 void
}
};