feat: 添加 passkey 登录功能
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { getDbInstance, runDb, getDb, allDb } from '../database/connection';
|
||||
@@ -8,13 +7,15 @@ import { NotificationService } from '../services/notification.service';
|
||||
import { AuditLogService } from '../services/audit.service';
|
||||
import { ipBlacklistService } from '../services/ip-blacklist.service';
|
||||
import { captchaService } from '../services/captcha.service';
|
||||
import { settingsService } from '../services/settings.service';
|
||||
|
||||
import { settingsService } from '../services/settings.service';
|
||||
import { passkeyService } from '../services/passkey.service'; // +++ Passkey Service
|
||||
import { passkeyRepository } from '../repositories/passkey.repository'; // +++ Passkey Repository
|
||||
import { userRepository } from '../repositories/user.repository'; // For passkey auth success
|
||||
|
||||
const notificationService = new NotificationService();
|
||||
const auditLogService = new AuditLogService();
|
||||
|
||||
export interface User { // Add export keyword
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
hashed_password: string;
|
||||
@@ -27,11 +28,260 @@ declare module 'express-session' {
|
||||
username?: string;
|
||||
tempTwoFactorSecret?: string;
|
||||
requiresTwoFactor?: boolean;
|
||||
// currentChallenge?: string; // Removed Passkey challenge storage
|
||||
currentChallenge?: string; // +++ For Passkey challenge storage
|
||||
passkeyUserHandle?: string; // +++ For Passkey user handle (user ID as string)
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Passkey Controller Methods ---
|
||||
|
||||
/**
|
||||
* 生成 Passkey 注册选项 (POST /api/v1/auth/passkey/registration-options)
|
||||
*/
|
||||
export const generatePasskeyRegistrationOptionsHandler = async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.session.userId;
|
||||
const username = req.session.username;
|
||||
|
||||
if (!userId || !username) {
|
||||
res.status(401).json({ message: '用户未认证。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// PasskeyService's generateRegistrationOptions expects userId as number
|
||||
const options = await passkeyService.generateRegistrationOptions(username, userId);
|
||||
|
||||
req.session.currentChallenge = options.challenge;
|
||||
// The user.id from options is a Uint8Array. We need to store the original string userId for userHandle.
|
||||
req.session.passkeyUserHandle = userId.toString();
|
||||
|
||||
console.log(`[AuthController] Generated Passkey registration options for user ${username}, challenge: ${options.challenge.substring(0,10)}...`);
|
||||
res.json(options);
|
||||
} catch (error: any) {
|
||||
console.error(`[AuthController] 生成 Passkey 注册选项时出错 (用户: ${username}):`, error.message, error.stack);
|
||||
res.status(500).json({ message: '生成 Passkey 注册选项失败。', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证并保存新的 Passkey (POST /api/v1/auth/passkey/register)
|
||||
*/
|
||||
export const verifyPasskeyRegistrationHandler = async (req: Request, res: Response): Promise<void> => {
|
||||
const registrationResponse = req.body; // The whole body is the response from @simplewebauthn/browser
|
||||
const expectedChallenge = req.session.currentChallenge;
|
||||
const userHandle = req.session.passkeyUserHandle;
|
||||
|
||||
if (!registrationResponse) {
|
||||
res.status(400).json({ message: '注册响应不能为空。' });
|
||||
return;
|
||||
}
|
||||
if (!expectedChallenge) {
|
||||
res.status(400).json({ message: '会话中未找到质询信息,请重试注册流程。' });
|
||||
return;
|
||||
}
|
||||
if (!userHandle) {
|
||||
res.status(400).json({ message: '会话中未找到用户句柄,请重试注册流程。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const verification = await passkeyService.verifyRegistration(
|
||||
registrationResponse,
|
||||
expectedChallenge,
|
||||
userHandle // userHandle is userId as string
|
||||
);
|
||||
|
||||
if (verification.verified && verification.newPasskeyToSave) {
|
||||
await passkeyRepository.createPasskey(verification.newPasskeyToSave);
|
||||
const userIdNum = parseInt(userHandle, 10);
|
||||
console.log(`[AuthController] 用户 ${userHandle} 的 Passkey 注册成功并已保存。 CredentialID: ${verification.newPasskeyToSave.credential_id}`);
|
||||
auditLogService.logAction('PASSKEY_REGISTERED', { userId: userIdNum, credentialId: verification.newPasskeyToSave.credential_id });
|
||||
notificationService.sendNotification('PASSKEY_REGISTERED', { userId: userIdNum, username: req.session.username, credentialId: verification.newPasskeyToSave.credential_id });
|
||||
|
||||
delete req.session.currentChallenge;
|
||||
delete req.session.passkeyUserHandle;
|
||||
res.status(201).json({ verified: true, message: 'Passkey 注册成功。' });
|
||||
} else {
|
||||
console.warn(`[AuthController] Passkey 注册验证失败 (用户: ${userHandle}):`, verification);
|
||||
res.status(400).json({ verified: false, message: 'Passkey 注册验证失败。', error: (verification as any).error?.message || 'Unknown verification error' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[AuthController] 验证 Passkey 注册时出错 (用户: ${userHandle}):`, error.message, error.stack);
|
||||
res.status(500).json({ verified: false, message: '验证 Passkey 注册失败。', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成 Passkey 认证选项 (POST /api/v1/auth/passkey/authentication-options)
|
||||
*/
|
||||
export const generatePasskeyAuthenticationOptionsHandler = async (req: Request, res: Response): Promise<void> => {
|
||||
const { username } = req.body; // Can be initiated by username (if not logged in) or for currently logged-in user
|
||||
|
||||
try {
|
||||
// PasskeyService's generateAuthenticationOptions can optionally take a username
|
||||
const options = await passkeyService.generateAuthenticationOptions(username);
|
||||
|
||||
req.session.currentChallenge = options.challenge;
|
||||
// For authentication, userHandle is not strictly needed in session beforehand if RP ID is specific enough
|
||||
// or if allowCredentials is used. We'll clear any old one just in case.
|
||||
delete req.session.passkeyUserHandle;
|
||||
|
||||
console.log(`[AuthController] Generated Passkey authentication options (username: ${username || 'any'}), challenge: ${options.challenge.substring(0,10)}...`);
|
||||
res.json(options);
|
||||
} catch (error: any) {
|
||||
console.error(`[AuthController] 生成 Passkey 认证选项时出错 (username: ${username || 'any'}):`, error.message, error.stack);
|
||||
res.status(500).json({ message: '生成 Passkey 认证选项失败。', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证 Passkey 凭据并登录用户 (POST /api/v1/auth/passkey/authenticate)
|
||||
*/
|
||||
export const verifyPasskeyAuthenticationHandler = async (req: Request, res: Response): Promise<void> => {
|
||||
const authenticationResponse = req.body; // The whole body is the response from @simplewebauthn/browser
|
||||
const expectedChallenge = req.session.currentChallenge;
|
||||
const { rememberMe } = req.body; // Optional rememberMe flag
|
||||
|
||||
if (!authenticationResponse) {
|
||||
res.status(400).json({ message: '认证响应不能为空。' });
|
||||
return;
|
||||
}
|
||||
if (!expectedChallenge) {
|
||||
res.status(400).json({ message: '会话中未找到质询信息,请重试认证流程。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const verification = await passkeyService.verifyAuthentication(
|
||||
authenticationResponse,
|
||||
expectedChallenge
|
||||
);
|
||||
|
||||
if (verification.verified && verification.userId && verification.passkey) {
|
||||
const user = await userRepository.findUserById(verification.userId);
|
||||
if (!user) {
|
||||
// This should ideally not happen if passkey verification was successful
|
||||
console.error(`[AuthController] Passkey 认证成功但未找到用户 ID: ${verification.userId}`);
|
||||
auditLogService.logAction('PASSKEY_AUTH_FAILURE', { credentialId: verification.passkey.credential_id, reason: 'User not found after verification' });
|
||||
res.status(401).json({ verified: false, message: 'Passkey 认证失败:用户数据错误。' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[AuthController] 用户 ${user.username} (ID: ${user.id}) 通过 Passkey (ID: ${verification.passkey.id}) 认证成功。`);
|
||||
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
auditLogService.logAction('PASSKEY_AUTH_SUCCESS', { userId: user.id, username: user.username, credentialId: verification.passkey.credential_id, ip: clientIp });
|
||||
notificationService.sendNotification('LOGIN_SUCCESS', { userId: user.id, username: user.username, ip: clientIp, method: 'Passkey' });
|
||||
|
||||
// Setup session similar to password login
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
req.session.requiresTwoFactor = false; // Passkey implies 2FA characteristics
|
||||
|
||||
if (rememberMe) {
|
||||
req.session.cookie.maxAge = 315360000000; // 10 years
|
||||
} else {
|
||||
req.session.cookie.maxAge = undefined; // Session cookie
|
||||
}
|
||||
|
||||
delete req.session.currentChallenge;
|
||||
delete req.session.passkeyUserHandle;
|
||||
|
||||
res.status(200).json({
|
||||
verified: true,
|
||||
message: 'Passkey 认证成功。',
|
||||
user: { id: user.id, username: user.username }
|
||||
});
|
||||
|
||||
} else {
|
||||
console.warn(`[AuthController] Passkey 认证验证失败:`, verification);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
auditLogService.logAction('PASSKEY_AUTH_FAILURE', {
|
||||
credentialId: authenticationResponse.id,
|
||||
reason: 'Verification failed',
|
||||
ip: clientIp
|
||||
});
|
||||
res.status(401).json({ verified: false, message: 'Passkey 认证失败。' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[AuthController] 验证 Passkey 认证时出错:`, error.message, error.stack);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
auditLogService.logAction('PASSKEY_AUTH_FAILURE', {
|
||||
credentialId: authenticationResponse?.id || 'unknown',
|
||||
reason: error.message,
|
||||
ip: clientIp
|
||||
});
|
||||
res.status(500).json({ verified: false, message: '验证 Passkey 认证失败。', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 获取当前认证用户的所有 Passkey (GET /api/v1/user/passkeys)
|
||||
*/
|
||||
export const listUserPasskeysHandler = async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.session.userId;
|
||||
const username = req.session.username;
|
||||
|
||||
if (!userId || !username) {
|
||||
res.status(401).json({ message: '用户未认证。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const passkeys = await passkeyService.listPasskeysByUserId(userId);
|
||||
console.log(`[AuthController] 用户 ${username} (ID: ${userId}) 获取了 Passkey 列表,数量: ${passkeys.length}`);
|
||||
res.status(200).json(passkeys);
|
||||
} catch (error: any) {
|
||||
console.error(`[AuthController] 用户 ${username} (ID: ${userId}) 获取 Passkey 列表时出错:`, error.message, error.stack);
|
||||
res.status(500).json({ message: '获取 Passkey 列表失败。', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除当前认证用户指定的 Passkey (DELETE /api/v1/user/passkeys/:credentialID)
|
||||
*/
|
||||
export const deleteUserPasskeyHandler = async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.session.userId;
|
||||
const username = req.session.username;
|
||||
const { credentialID } = req.params;
|
||||
|
||||
if (!userId || !username) {
|
||||
res.status(401).json({ message: '用户未认证。' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!credentialID) {
|
||||
res.status(400).json({ message: '必须提供 Passkey 的 CredentialID。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const wasDeleted = await passkeyService.deletePasskey(userId, credentialID);
|
||||
if (wasDeleted) {
|
||||
console.log(`[AuthController] 用户 ${username} (ID: ${userId}) 成功删除了 Passkey (CredentialID: ${credentialID})。`);
|
||||
auditLogService.logAction('PASSKEY_DELETED', { userId, username, credentialId: credentialID });
|
||||
notificationService.sendNotification('PASSKEY_DELETED', { userId, username, credentialId: credentialID });
|
||||
res.status(200).json({ message: 'Passkey 删除成功。' });
|
||||
} else {
|
||||
// 这通常不应该发生,因为 service 层会在找不到或权限不足时抛出错误
|
||||
console.warn(`[AuthController] 用户 ${username} (ID: ${userId}) 删除 Passkey (CredentialID: ${credentialID}) 失败,但未抛出错误。`);
|
||||
res.status(404).json({ message: 'Passkey 未找到或无法删除。' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[AuthController] 用户 ${username} (ID: ${userId}) 删除 Passkey (CredentialID: ${credentialID}) 时出错:`, error.message, error.stack);
|
||||
if (error.message === 'Passkey not found.') {
|
||||
res.status(404).json({ message: '指定的 Passkey 未找到。' });
|
||||
} else if (error.message === 'Unauthorized to delete this passkey.') {
|
||||
auditLogService.logAction('PASSKEY_DELETE_UNAUTHORIZED', { userId, username, credentialIdAttempted: credentialID });
|
||||
res.status(403).json({ message: '无权删除此 Passkey。' });
|
||||
} else {
|
||||
res.status(500).json({ message: '删除 Passkey 失败。', error: error.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 处理用户登录请求 (POST /api/v1/auth/login)
|
||||
@@ -61,7 +311,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
ipBlacklistService.recordFailedAttempt(clientIp);
|
||||
auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp });
|
||||
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp }); // 取消注释
|
||||
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp });
|
||||
res.status(401).json({ message: 'CAPTCHA 验证失败。' });
|
||||
return;
|
||||
}
|
||||
@@ -84,12 +334,9 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!user) {
|
||||
console.log(`登录尝试失败: 用户未找到 - ${username}`);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
// 记录失败尝试
|
||||
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 }); // 取消注释
|
||||
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'User not found', ip: clientIp });
|
||||
res.status(401).json({ message: '无效的凭据。' });
|
||||
return;
|
||||
}
|
||||
@@ -98,13 +345,10 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
if (!isMatch) {
|
||||
console.log(`登录尝试失败: 密码错误 - ${username}`);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
|
||||
// 记录失败尝试
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
ipBlacklistService.recordFailedAttempt(clientIp);
|
||||
// 记录审计日志 (添加 IP)
|
||||
auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp });
|
||||
// 发送登录失败通知
|
||||
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp }); // 取消注释
|
||||
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp });
|
||||
res.status(401).json({ message: '无效的凭据。' });
|
||||
return;
|
||||
}
|
||||
@@ -112,30 +356,23 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
// 检查是否启用了 2FA
|
||||
if (user.two_factor_secret) {
|
||||
console.log(`用户 ${username} 已启用 2FA,需要进行二次验证。`);
|
||||
// 不设置完整 session,只标记需要 2FA
|
||||
req.session.userId = user.id; // 临时存储 userId 以便 2FA 验证
|
||||
req.session.userId = user.id;
|
||||
req.session.requiresTwoFactor = true;
|
||||
req.session.rememberMe = rememberMe; // 临时存储 rememberMe 状态
|
||||
req.session.rememberMe = rememberMe;
|
||||
res.status(200).json({ message: '需要进行两步验证。', requiresTwoFactor: true });
|
||||
} else {
|
||||
// --- 认证成功 (未启用 2FA) ---
|
||||
console.log(`登录成功 (无 2FA): ${username}`);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
|
||||
// 重置失败尝试次数
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
ipBlacklistService.resetAttempts(clientIp);
|
||||
// 记录审计日志 (添加 IP)
|
||||
auditLogService.logAction('LOGIN_SUCCESS', { userId: user.id, username, ip: clientIp });
|
||||
notificationService.sendNotification('LOGIN_SUCCESS', { userId: user.id, username, ip: clientIp }); // 添加通知调用
|
||||
notificationService.sendNotification('LOGIN_SUCCESS', { userId: user.id, username, ip: clientIp });
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
req.session.requiresTwoFactor = false; // 明确标记不需要 2FA
|
||||
req.session.requiresTwoFactor = false;
|
||||
|
||||
// 根据 rememberMe 设置 cookie maxAge
|
||||
if (rememberMe) {
|
||||
// 如果勾选了“记住我”,设置 cookie 有效期为 10 年 (毫秒)
|
||||
req.session.cookie.maxAge = 315360000000;
|
||||
} else {
|
||||
// 如果未勾选,则不设置 maxAge,使其成为会话 cookie
|
||||
req.session.cookie.maxAge = undefined;
|
||||
}
|
||||
|
||||
@@ -159,17 +396,14 @@ export const getAuthStatus = async (req: Request, res: Response): Promise<void>
|
||||
const username = req.session.username;
|
||||
|
||||
if (!userId || !username || req.session.requiresTwoFactor) {
|
||||
// 如果 session 无效或 2FA 未完成,视为未认证
|
||||
res.status(401).json({ isAuthenticated: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getDbInstance(); // Get DB instance
|
||||
// 查询用户的 2FA 状态 using promisified getDb
|
||||
const db = await getDbInstance();
|
||||
const user = await getDb<{ two_factor_secret: string | null }>(db, 'SELECT two_factor_secret FROM users WHERE id = ?', [userId]);
|
||||
|
||||
// 如果找不到用户,也视为未认证
|
||||
if (!user) {
|
||||
res.status(401).json({ isAuthenticated: false });
|
||||
return;
|
||||
@@ -180,7 +414,7 @@ export const getAuthStatus = async (req: Request, res: Response): Promise<void>
|
||||
user: {
|
||||
id: userId,
|
||||
username: username,
|
||||
isTwoFactorEnabled: !!user.two_factor_secret // 返回 2FA 是否启用
|
||||
isTwoFactorEnabled: !!user.two_factor_secret
|
||||
}
|
||||
});
|
||||
|
||||
@@ -194,9 +428,8 @@ export const getAuthStatus = async (req: Request, res: Response): Promise<void>
|
||||
*/
|
||||
export const verifyLogin2FA = async (req: Request, res: Response): Promise<void> => {
|
||||
const { token } = req.body;
|
||||
const userId = req.session.userId; // 获取之前临时存储的 userId
|
||||
const userId = req.session.userId;
|
||||
|
||||
// 检查 session 状态
|
||||
if (!userId || !req.session.requiresTwoFactor) {
|
||||
res.status(400).json({ message: '无效的请求或会话状态。' });
|
||||
return;
|
||||
@@ -209,7 +442,6 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise<void>
|
||||
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
// 获取用户的 2FA 密钥 using promisified getDb
|
||||
const user = await getDb<User>(db, 'SELECT id, username, two_factor_secret FROM users WHERE id = ?', [userId]);
|
||||
|
||||
|
||||
@@ -220,35 +452,27 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise<void>
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 TOTP 令牌
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: user.two_factor_secret,
|
||||
encoding: 'base32',
|
||||
token: token,
|
||||
window: 1 // 允许前后一个时间窗口 (30秒) 的容错
|
||||
window: 1
|
||||
});
|
||||
|
||||
if (verified) {
|
||||
console.log(`用户 ${user.username} 2FA 验证成功。`);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
|
||||
// 重置失败尝试次数
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
ipBlacklistService.resetAttempts(clientIp);
|
||||
// 记录审计日志 (2FA 成功也算登录成功) (添加 IP)
|
||||
auditLogService.logAction('LOGIN_SUCCESS', { userId: user.id, username: user.username, ip: clientIp, twoFactor: true });
|
||||
notificationService.sendNotification('LOGIN_SUCCESS', { userId: user.id, username: user.username, ip: clientIp, twoFactor: true }); // 添加通知调用
|
||||
// 验证成功,建立完整会话
|
||||
notificationService.sendNotification('LOGIN_SUCCESS', { userId: user.id, username: user.username, ip: clientIp, twoFactor: true });
|
||||
req.session.username = user.username;
|
||||
req.session.requiresTwoFactor = false; // 标记 2FA 已完成
|
||||
req.session.requiresTwoFactor = false;
|
||||
|
||||
// 根据之前存储在 session 中的 rememberMe 设置 cookie maxAge
|
||||
if (req.session.rememberMe) {
|
||||
// 如果勾选了“记住我”,设置 cookie 有效期为 1 年 (毫秒)
|
||||
req.session.cookie.maxAge = 315360000000; // 10 years (Effectively permanent)
|
||||
req.session.cookie.maxAge = 315360000000;
|
||||
} else {
|
||||
// 如果未勾选,则不设置 maxAge,使其成为会话 cookie
|
||||
req.session.cookie.maxAge = undefined; // 或者 null
|
||||
req.session.cookie.maxAge = undefined;
|
||||
}
|
||||
// 清除临时的 rememberMe 状态
|
||||
delete req.session.rememberMe;
|
||||
|
||||
res.status(200).json({
|
||||
@@ -257,12 +481,10 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise<void>
|
||||
});
|
||||
} else {
|
||||
console.log(`用户 ${user.username} 2FA 验证失败: 验证码错误。`);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
|
||||
// 记录失败尝试
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
ipBlacklistService.recordFailedAttempt(clientIp);
|
||||
// 记录审计日志 (添加 IP)
|
||||
auditLogService.logAction('LOGIN_FAILURE', { userId: user.id, username: user.username, reason: 'Invalid 2FA token', ip: clientIp });
|
||||
notificationService.sendNotification('LOGIN_FAILURE', { userId: user.id, username: user.username, reason: 'Invalid 2FA token', ip: clientIp }); // 添加通知调用
|
||||
notificationService.sendNotification('LOGIN_FAILURE', { userId: user.id, username: user.username, reason: 'Invalid 2FA token', ip: clientIp });
|
||||
res.status(401).json({ message: '验证码无效。' });
|
||||
}
|
||||
|
||||
@@ -278,15 +500,13 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise<void>
|
||||
*/
|
||||
export const changePassword = async (req: Request, res: Response): Promise<void> => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const userId = req.session.userId; // 从会话中获取用户 ID
|
||||
const userId = req.session.userId;
|
||||
|
||||
// 检查用户是否登录且 2FA 已完成 (如果需要)
|
||||
if (!userId || req.session.requiresTwoFactor) {
|
||||
res.status(401).json({ message: '用户未认证或认证未完成,请先登录。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 基础输入验证
|
||||
if (!currentPassword || !newPassword) {
|
||||
res.status(400).json({ message: '当前密码和新密码不能为空。' });
|
||||
return;
|
||||
@@ -335,10 +555,9 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
console.log(`用户 ${userId} 密码已成功修改。`);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
|
||||
// 记录审计日志 (添加 IP)
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
auditLogService.logAction('PASSWORD_CHANGED', { userId, ip: clientIp });
|
||||
notificationService.sendNotification('PASSWORD_CHANGED', { userId, ip: clientIp }); // 添加通知调用
|
||||
notificationService.sendNotification('PASSWORD_CHANGED', { userId, ip: clientIp });
|
||||
|
||||
res.status(200).json({ message: '密码已成功修改。' });
|
||||
|
||||
@@ -354,7 +573,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
*/
|
||||
export const setup2FA = async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.session.userId;
|
||||
const username = req.session.username; // 获取用户名用于 OTP URL
|
||||
const username = req.session.username;
|
||||
|
||||
if (!userId || !username || req.session.requiresTwoFactor) {
|
||||
res.status(401).json({ message: '用户未认证或认证未完成。' });
|
||||
@@ -363,7 +582,6 @@ export const setup2FA = async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
// 检查用户是否已启用 2FA using promisified getDb
|
||||
const user = await getDb<{ two_factor_secret: string | null }>(db, 'SELECT two_factor_secret FROM users WHERE id = ?', [userId]);
|
||||
const existingSecret = user ? user.two_factor_secret : null;
|
||||
|
||||
@@ -373,30 +591,25 @@ export const setup2FA = async (req: Request, res: Response): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成新的 2FA 密钥
|
||||
const secret = speakeasy.generateSecret({
|
||||
length: 20,
|
||||
name: `NexusTerminal (${username})` // 应用名称和用户名,显示在 Authenticator 应用中
|
||||
name: `NexusTerminal (${username})`
|
||||
});
|
||||
|
||||
// 将临时密钥存储在 session 中,等待验证
|
||||
req.session.tempTwoFactorSecret = secret.base32;
|
||||
|
||||
// 生成 OTP Auth URL (用于生成二维码)
|
||||
if (!secret.otpauth_url) {
|
||||
throw new Error('无法生成 OTP Auth URL');
|
||||
}
|
||||
|
||||
// 生成二维码 Data URL
|
||||
qrcode.toDataURL(secret.otpauth_url, (err, data_url) => {
|
||||
if (err) {
|
||||
console.error('生成二维码时出错:', err);
|
||||
throw new Error('生成二维码失败');
|
||||
}
|
||||
// 返回密钥 (base32) 和二维码数据 URL 给前端
|
||||
res.json({
|
||||
secret: secret.base32, // 供用户手动输入
|
||||
qrCodeUrl: data_url // 用于显示二维码图片
|
||||
secret: secret.base32,
|
||||
qrCodeUrl: data_url
|
||||
});
|
||||
});
|
||||
|
||||
@@ -415,7 +628,7 @@ export const setup2FA = async (req: Request, res: Response): Promise<void> => {
|
||||
export const verifyAndActivate2FA = async (req: Request, res: Response): Promise<void> => {
|
||||
const { token } = req.body;
|
||||
const userId = req.session.userId;
|
||||
const tempSecret = req.session.tempTwoFactorSecret; // 获取存储在 session 中的临时密钥
|
||||
const tempSecret = req.session.tempTwoFactorSecret;
|
||||
|
||||
if (!userId || req.session.requiresTwoFactor) {
|
||||
res.status(401).json({ message: '用户未认证或认证未完成。' });
|
||||
@@ -434,16 +647,14 @@ export const verifyAndActivate2FA = async (req: Request, res: Response): Promise
|
||||
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
// 使用临时密钥验证用户提交的令牌
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: tempSecret,
|
||||
encoding: 'base32',
|
||||
token: token,
|
||||
window: 1 // 允许一定的时钟漂移
|
||||
window: 1
|
||||
});
|
||||
|
||||
if (verified) {
|
||||
// 验证成功,将密钥永久存储到数据库 using promisified runDb
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const result = await runDb(db,
|
||||
'UPDATE users SET two_factor_secret = ?, updated_at = ? WHERE id = ?',
|
||||
@@ -457,16 +668,13 @@ export const verifyAndActivate2FA = async (req: Request, res: Response): Promise
|
||||
|
||||
console.log(`用户 ${userId} 已成功激活两步验证。`);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
// 记录审计日志 (添加 IP)
|
||||
auditLogService.logAction('2FA_ENABLED', { userId, ip: clientIp });
|
||||
notificationService.sendNotification('2FA_ENABLED', { userId, ip: clientIp }); // 添加通知调用
|
||||
notificationService.sendNotification('2FA_ENABLED', { userId, ip: clientIp });
|
||||
|
||||
// 清除 session 中的临时密钥
|
||||
delete req.session.tempTwoFactorSecret;
|
||||
|
||||
res.status(200).json({ message: '两步验证已成功激活!' });
|
||||
} else {
|
||||
// 验证失败
|
||||
console.log(`用户 ${userId} 2FA 激活失败: 验证码错误。`);
|
||||
res.status(400).json({ message: '验证码无效。' });
|
||||
}
|
||||
@@ -482,7 +690,7 @@ export const verifyAndActivate2FA = async (req: Request, res: Response): Promise
|
||||
*/
|
||||
export const disable2FA = async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.session.userId;
|
||||
const { password } = req.body; // 需要验证当前密码以禁用
|
||||
const { password } = req.body;
|
||||
|
||||
if (!userId || req.session.requiresTwoFactor) {
|
||||
res.status(401).json({ message: '用户未认证或认证未完成。' });
|
||||
@@ -496,7 +704,6 @@ export const disable2FA = async (req: Request, res: Response): Promise<void> =>
|
||||
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
// 验证当前密码
|
||||
const user = await getDb<User>(db, 'SELECT id, hashed_password FROM users WHERE id = ?', [userId]);
|
||||
|
||||
if (!user) {
|
||||
@@ -507,7 +714,6 @@ export const disable2FA = async (req: Request, res: Response): Promise<void> =>
|
||||
res.status(400).json({ message: '当前密码不正确。' }); return;
|
||||
}
|
||||
|
||||
// 清除数据库中的 2FA 密钥
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const result = await runDb(db,
|
||||
'UPDATE users SET two_factor_secret = NULL, updated_at = ? WHERE id = ?',
|
||||
@@ -521,9 +727,8 @@ export const disable2FA = async (req: Request, res: Response): Promise<void> =>
|
||||
|
||||
console.log(`用户 ${userId} 已成功禁用两步验证。`);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
// 记录审计日志 (添加 IP)
|
||||
auditLogService.logAction('2FA_DISABLED', { userId, ip: clientIp });
|
||||
notificationService.sendNotification('2FA_DISABLED', { userId, ip: clientIp }); // 添加通知调用
|
||||
notificationService.sendNotification('2FA_DISABLED', { userId, ip: clientIp });
|
||||
|
||||
res.status(200).json({ message: '两步验证已成功禁用。' });
|
||||
|
||||
@@ -539,8 +744,7 @@ export const disable2FA = async (req: Request, res: Response): Promise<void> =>
|
||||
*/
|
||||
export const needsSetup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const db = await getDbInstance(); // Get DB instance
|
||||
// Use promisified getDb
|
||||
const db = await getDbInstance();
|
||||
const row = await getDb<{ count: number }>(db, 'SELECT COUNT(*) as count FROM users');
|
||||
const userCount = row ? row.count : 0;
|
||||
|
||||
@@ -548,7 +752,6 @@ export const needsSetup = async (req: Request, res: Response): Promise<void> =>
|
||||
|
||||
} catch (error) {
|
||||
console.error('检查设置状态时发生内部错误:', error);
|
||||
// 如果检查失败,保守起见返回 false,避免用户卡在设置页面
|
||||
res.status(500).json({ message: '检查设置状态时发生错误。', needsSetup: false });
|
||||
}
|
||||
};
|
||||
@@ -559,7 +762,6 @@ export const needsSetup = async (req: Request, res: Response): Promise<void> =>
|
||||
export const setupAdmin = async (req: Request, res: Response): Promise<void> => {
|
||||
const { username, password, confirmPassword } = req.body;
|
||||
|
||||
// 基本输入验证
|
||||
if (!username || !password || !confirmPassword) {
|
||||
res.status(400).json({ message: '用户名、密码和确认密码不能为空。' });
|
||||
return;
|
||||
@@ -576,7 +778,6 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
|
||||
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
// 检查数据库中是否已存在用户
|
||||
const row = await getDb<{ count: number }>(db, 'SELECT COUNT(*) as count FROM users');
|
||||
const userCount = row ? row.count : 0;
|
||||
|
||||
@@ -586,12 +787,10 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
|
||||
return;
|
||||
}
|
||||
|
||||
// 哈希密码
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// 插入新用户
|
||||
const result = await runDb(db,
|
||||
`INSERT INTO users (username, hashed_password, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
@@ -607,9 +806,8 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
|
||||
|
||||
console.log(`初始管理员账号 '${username}' (ID: ${newUser.id}) 已成功创建。`);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
// 记录审计日志 (添加 IP)
|
||||
auditLogService.logAction('ADMIN_SETUP_COMPLETE', { userId: newUser.id, username, ip: clientIp });
|
||||
notificationService.sendNotification('ADMIN_SETUP_COMPLETE', { userId: newUser.id, username, ip: clientIp }); // 添加通知调用
|
||||
notificationService.sendNotification('ADMIN_SETUP_COMPLETE', { userId: newUser.id, username, ip: clientIp });
|
||||
|
||||
res.status(201).json({ message: '初始管理员账号创建成功!' });
|
||||
|
||||
@@ -629,17 +827,14 @@ export const logout = (req: Request, res: Response): void => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
console.error(`销毁用户 ${userId} (${username}) 的会话时出错:`, err);
|
||||
// 即使销毁失败,也尝试让前端认为已登出
|
||||
res.status(500).json({ message: '登出时发生服务器内部错误。' });
|
||||
} else {
|
||||
console.log(`用户 ${userId} (${username}) 已成功登出。`);
|
||||
// 清除客户端的 session cookie (通常 connect-sqlite3 会处理,但显式设置更保险)
|
||||
res.clearCookie('connect.sid'); // 'connect.sid' 是 express-session 的默认 cookie 名称
|
||||
// 记录审计日志
|
||||
if (userId) { // 仅在能获取到 userId 时记录
|
||||
res.clearCookie('connect.sid');
|
||||
if (userId) {
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
auditLogService.logAction('LOGOUT', { userId, username, ip: clientIp });
|
||||
notificationService.sendNotification('LOGOUT', { userId, username, ip: clientIp }); // 添加通知调用
|
||||
notificationService.sendNotification('LOGOUT', { userId, username, ip: clientIp });
|
||||
}
|
||||
res.status(200).json({ message: '已成功登出。' });
|
||||
}
|
||||
@@ -666,7 +861,6 @@ export const getPublicCaptchaConfig = async (req: Request, res: Response): Promi
|
||||
res.status(200).json(publicConfig);
|
||||
} catch (error: any) {
|
||||
console.error('[AuthController] 获取公共 CAPTCHA 配置时出错:', error);
|
||||
// 即使出错,也返回一个“禁用”状态,避免前端出错
|
||||
res.status(500).json({
|
||||
enabled: false,
|
||||
provider: 'none',
|
||||
|
||||
@@ -7,11 +7,18 @@ import {
|
||||
verifyAndActivate2FA,
|
||||
disable2FA,
|
||||
getAuthStatus,
|
||||
// Removed Passkey imports
|
||||
needsSetup,
|
||||
setupAdmin,
|
||||
logout,
|
||||
getPublicCaptchaConfig
|
||||
getPublicCaptchaConfig,
|
||||
// Passkey handlers
|
||||
generatePasskeyRegistrationOptionsHandler,
|
||||
verifyPasskeyRegistrationHandler,
|
||||
generatePasskeyAuthenticationOptionsHandler,
|
||||
verifyPasskeyAuthenticationHandler,
|
||||
// 新的 Passkey 管理处理器
|
||||
listUserPasskeysHandler,
|
||||
deleteUserPasskeyHandler
|
||||
} from './auth.controller';
|
||||
import { isAuthenticated } from './auth.middleware';
|
||||
import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware';
|
||||
@@ -52,7 +59,26 @@ router.delete('/2fa', isAuthenticated, disable2FA);
|
||||
// GET /api/v1/auth/status - 获取当前认证状态 (需要认证)
|
||||
router.get('/status', isAuthenticated, getAuthStatus);
|
||||
|
||||
// --- Passkey routes removed ---
|
||||
// --- Passkey Routes ---
|
||||
// POST /api/v1/auth/passkey/registration-options - 生成 Passkey 注册选项 (需要认证)
|
||||
router.post('/passkey/registration-options', isAuthenticated, generatePasskeyRegistrationOptionsHandler);
|
||||
|
||||
// POST /api/v1/auth/passkey/register - 验证并保存新的 Passkey (需要认证,因为通常在已登录会话中添加新凭据)
|
||||
router.post('/passkey/register', isAuthenticated, verifyPasskeyRegistrationHandler);
|
||||
|
||||
// POST /api/v1/auth/passkey/authentication-options - 生成 Passkey 认证选项 (公开或半公开,取决于是否提供了用户名)
|
||||
router.post('/passkey/authentication-options', generatePasskeyAuthenticationOptionsHandler);
|
||||
|
||||
// POST /api/v1/auth/passkey/authenticate - 验证 Passkey 并登录用户 (公开)
|
||||
router.post('/passkey/authenticate', ipBlacklistCheckMiddleware, verifyPasskeyAuthenticationHandler);
|
||||
|
||||
// --- User's Passkey Management Routes (New) ---
|
||||
// GET /api/v1/auth/user/passkeys - 获取当前用户的所有 Passkey (需要认证)
|
||||
router.get('/user/passkeys', isAuthenticated, listUserPasskeysHandler);
|
||||
|
||||
// DELETE /api/v1/auth/user/passkeys/:credentialID - 删除当前用户指定的 Passkey (需要认证)
|
||||
router.delete('/user/passkeys/:credentialID', isAuthenticated, deleteUserPasskeyHandler);
|
||||
|
||||
|
||||
// POST /api/v1/auth/logout - 用户登出接口 (公开访问)
|
||||
router.post('/logout', logout);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Basic application configuration
|
||||
// In a real application, consider using a more robust config library like 'dotenv' or 'convict'
|
||||
|
||||
interface AppConfig {
|
||||
appName: string;
|
||||
rpId: string; // Relying Party ID for WebAuthn
|
||||
rpOrigin: string; // Relying Party Origin for WebAuthn
|
||||
port: number;
|
||||
// Add other application-wide configurations here
|
||||
}
|
||||
|
||||
export const config: AppConfig = {
|
||||
appName: process.env.APP_NAME || 'Nexus Terminal',
|
||||
rpId: process.env.RP_ID || 'localhost', // IMPORTANT: This MUST match your domain in production
|
||||
rpOrigin: process.env.RP_ORIGIN || 'http://localhost:5173', // IMPORTANT: This MUST match your frontend origin in production
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
};
|
||||
|
||||
// Function to get a config value, though direct access is also possible
|
||||
export function getConfigValue<K extends keyof AppConfig>(key: K): AppConfig[K] {
|
||||
return config[key];
|
||||
}
|
||||
@@ -228,6 +228,30 @@ const definedMigrations: Migration[] = [
|
||||
`
|
||||
},
|
||||
// --- 未来可以添加更多迁移 ---
|
||||
{
|
||||
id: 6,
|
||||
name: 'Create passkeys table for WebAuthn credentials',
|
||||
check: async (db: Database): Promise<boolean> => {
|
||||
const passkeysTableAlreadyExists = await tableExists(db, 'passkeys');
|
||||
return !passkeysTableAlreadyExists; // Only run if the table does NOT exist
|
||||
},
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS passkeys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
credential_id TEXT UNIQUE NOT NULL, -- Base64URL encoded
|
||||
public_key TEXT NOT NULL, -- COSE public key, stored as Base64URL or HEX
|
||||
counter INTEGER NOT NULL,
|
||||
transports TEXT, -- JSON array of transports e.g. ["usb", "nfc", "ble", "internal"]
|
||||
name TEXT NULL, -- User-friendly name for the passkey
|
||||
backed_up BOOLEAN NOT NULL DEFAULT FALSE, -- Stored as 0 or 1
|
||||
last_used_at INTEGER NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,7 +28,23 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
// );
|
||||
// `;
|
||||
|
||||
// Removed Passkeys table definition (lines 31-44 from original)
|
||||
// Passkeys table definition
|
||||
export const createPasskeysTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS passkeys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
credential_id TEXT UNIQUE NOT NULL, -- Base64URL encoded
|
||||
public_key TEXT NOT NULL, -- COSE public key, stored as Base64URL or HEX
|
||||
counter INTEGER NOT NULL,
|
||||
transports TEXT, -- JSON array of transports e.g. ["usb", "nfc", "ble", "internal"]
|
||||
name TEXT NULL, -- User-friendly name for the passkey
|
||||
backed_up BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_used_at INTEGER NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
export const createNotificationSettingsTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS notification_settings (
|
||||
|
||||
@@ -1 +1,132 @@
|
||||
// This file is intentionally left empty as Passkey functionality has been removed.
|
||||
import { getDbInstance, runDb, getDb, allDb } from '../database/connection';
|
||||
|
||||
export interface Passkey {
|
||||
id: number;
|
||||
user_id: number;
|
||||
credential_id: string;
|
||||
public_key: string;
|
||||
counter: number;
|
||||
transports: string | null; // JSON string
|
||||
name: string | null;
|
||||
backed_up: boolean; // SQLite stores booleans as 0 or 1
|
||||
last_used_at: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface NewPasskey {
|
||||
user_id: number;
|
||||
credential_id: string;
|
||||
public_key: string;
|
||||
counter: number;
|
||||
transports?: string | null; // JSON string
|
||||
name?: string | null;
|
||||
backed_up?: boolean;
|
||||
}
|
||||
|
||||
// Helper to convert DB result (0/1) to boolean for backed_up field
|
||||
function mapPasskeyResult(dbResult: any): Passkey | null {
|
||||
if (!dbResult) return null;
|
||||
return {
|
||||
...dbResult,
|
||||
backed_up: !!dbResult.backed_up, // Ensure boolean
|
||||
transports: dbResult.transports, // Already string or null
|
||||
};
|
||||
}
|
||||
|
||||
function mapPasskeyResults(dbResults: any[]): Passkey[] {
|
||||
return dbResults.map(row => ({
|
||||
...row,
|
||||
backed_up: !!row.backed_up,
|
||||
transports: row.transports,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
export class PasskeyRepository {
|
||||
async createPasskey(passkeyData: NewPasskey): Promise<Passkey> {
|
||||
const db = await getDbInstance();
|
||||
const sql = `
|
||||
INSERT INTO passkeys (user_id, credential_id, public_key, counter, transports, name, backed_up, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
|
||||
RETURNING *
|
||||
`;
|
||||
// Note: RETURNING * might not work as expected with the 'sqlite3' package's run method.
|
||||
// We'll do a SELECT after INSERT if needed, or rely on lastID and then select.
|
||||
// For simplicity with 'sqlite3', we'll insert then select.
|
||||
|
||||
const insertSql = `
|
||||
INSERT INTO passkeys (user_id, credential_id, public_key, counter, transports, name, backed_up, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
|
||||
`;
|
||||
const params = [
|
||||
passkeyData.user_id,
|
||||
passkeyData.credential_id,
|
||||
passkeyData.public_key,
|
||||
passkeyData.counter,
|
||||
passkeyData.transports ?? null,
|
||||
passkeyData.name ?? null,
|
||||
passkeyData.backed_up ? 1 : 0, // Store boolean as 0 or 1
|
||||
];
|
||||
|
||||
const { lastID } = await runDb(db, insertSql, params);
|
||||
|
||||
// Fetch the inserted row
|
||||
const newPasskey = await this.getPasskeyById(lastID);
|
||||
if (!newPasskey) {
|
||||
throw new Error('Failed to create or retrieve passkey after insert.');
|
||||
}
|
||||
return newPasskey;
|
||||
}
|
||||
|
||||
async getPasskeyById(id: number): Promise<Passkey | null> {
|
||||
const db = await getDbInstance();
|
||||
const sql = 'SELECT * FROM passkeys WHERE id = ?';
|
||||
const result = await getDb<any>(db, sql, [id]);
|
||||
return mapPasskeyResult(result);
|
||||
}
|
||||
|
||||
async getPasskeyByCredentialId(credentialId: string): Promise<Passkey | null> {
|
||||
const db = await getDbInstance();
|
||||
const sql = 'SELECT * FROM passkeys WHERE credential_id = ?';
|
||||
const result = await getDb<any>(db, sql, [credentialId]);
|
||||
return mapPasskeyResult(result);
|
||||
}
|
||||
|
||||
async getPasskeysByUserId(userId: number): Promise<Passkey[]> {
|
||||
const db = await getDbInstance();
|
||||
const sql = 'SELECT * FROM passkeys WHERE user_id = ? ORDER BY created_at DESC';
|
||||
const results = await allDb<any>(db, sql, [userId]);
|
||||
return mapPasskeyResults(results);
|
||||
}
|
||||
|
||||
async updatePasskeyCounter(credentialId: string, newCounter: number): Promise<boolean> {
|
||||
const db = await getDbInstance();
|
||||
const sql = "UPDATE passkeys SET counter = ?, updated_at = strftime('%s', 'now') WHERE credential_id = ?";
|
||||
const { changes } = await runDb(db, sql, [newCounter, credentialId]);
|
||||
return changes > 0;
|
||||
}
|
||||
|
||||
async updatePasskeyLastUsedAt(credentialId: string): Promise<boolean> {
|
||||
const db = await getDbInstance();
|
||||
const sql = "UPDATE passkeys SET last_used_at = strftime('%s', 'now'), updated_at = strftime('%s', 'now') WHERE credential_id = ?";
|
||||
const { changes } = await runDb(db, sql, [credentialId]);
|
||||
return changes > 0;
|
||||
}
|
||||
|
||||
async deletePasskey(credentialId: string): Promise<boolean> {
|
||||
const db = await getDbInstance();
|
||||
const sql = 'DELETE FROM passkeys WHERE credential_id = ?';
|
||||
const { changes } = await runDb(db, sql, [credentialId]);
|
||||
return changes > 0;
|
||||
}
|
||||
|
||||
async deletePasskeysByUserId(userId: number): Promise<boolean> {
|
||||
const db = await getDbInstance();
|
||||
const sql = 'DELETE FROM passkeys WHERE user_id = ?';
|
||||
const { changes } = await runDb(db, sql, [userId]);
|
||||
return changes > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const passkeyRepository = new PasskeyRepository();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getDbInstance, getDb, allDb } from '../database/connection';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
hashed_password?: string; // Optional, as not always needed by consumers
|
||||
two_factor_secret?: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export class UserRepository {
|
||||
async findUserById(id: number): Promise<User | null> {
|
||||
const db = await getDbInstance();
|
||||
const sql = 'SELECT id, username, hashed_password, two_factor_secret, created_at, updated_at FROM users WHERE id = ?';
|
||||
const user = await getDb<User>(db, sql, [id]);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
async findUserByUsername(username: string): Promise<User | null> {
|
||||
const db = await getDbInstance();
|
||||
const sql = 'SELECT id, username, hashed_password, two_factor_secret, created_at, updated_at FROM users WHERE username = ?';
|
||||
const user = await getDb<User>(db, sql, [username]);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
// Add other user-related methods if needed, e.g., createUser, updateUserPassword, etc.
|
||||
}
|
||||
|
||||
export const userRepository = new UserRepository();
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
generateRegistrationOptions as generateRegOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions as generateAuthOptions,
|
||||
verifyAuthenticationResponse,
|
||||
VerifiedRegistrationResponse,
|
||||
VerifiedAuthenticationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import type {
|
||||
GenerateRegistrationOptionsOpts,
|
||||
GenerateAuthenticationOptionsOpts,
|
||||
VerifyRegistrationResponseOpts,
|
||||
VerifyAuthenticationResponseOpts,
|
||||
AuthenticatorTransportFuture,
|
||||
RegistrationResponseJSON,
|
||||
AuthenticationResponseJSON,
|
||||
// The actual type for verification.registrationInfo is RegistrationInfo within @simplewebauthn/server
|
||||
// and for verification.authenticationInfo is AuthenticationInfo.
|
||||
// We will rely on TypeScript's inference from the VerifiedRegistrationResponse/VerifiedAuthenticationResponse types.
|
||||
} from '@simplewebauthn/server';
|
||||
import { passkeyRepository, Passkey, NewPasskey } from '../repositories/passkey.repository';
|
||||
import { userRepository, User } from '../repositories/user.repository';
|
||||
import { config } from '../config/app.config';
|
||||
|
||||
const RP_ID = config.rpId;
|
||||
const RP_ORIGIN = config.rpOrigin;
|
||||
const RP_NAME = config.appName;
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
function base64UrlToUint8Array(base64urlString: string): Uint8Array {
|
||||
const base64 = base64urlString.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padLength = (4 - (base64.length % 4)) % 4;
|
||||
const paddedBase64 = base64 + '='.repeat(padLength);
|
||||
try {
|
||||
const binaryString = atob(paddedBase64);
|
||||
const uint8Array = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
uint8Array[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return uint8Array;
|
||||
} catch (e) {
|
||||
console.error("Failed to decode base64url string:", base64urlString, e);
|
||||
throw new Error("Invalid base64url string");
|
||||
}
|
||||
}
|
||||
|
||||
export class PasskeyService {
|
||||
constructor(
|
||||
private passkeyRepo: typeof passkeyRepository,
|
||||
private userRepo: typeof userRepository
|
||||
) {}
|
||||
|
||||
async generateRegistrationOptions(username: string, userId: number) {
|
||||
const user = await this.userRepo.findUserById(userId);
|
||||
if (!user || user.username !== username) {
|
||||
throw new Error('User not found or username mismatch');
|
||||
}
|
||||
|
||||
const existingPasskeys = await this.passkeyRepo.getPasskeysByUserId(userId);
|
||||
|
||||
const excludeCredentials: {id: string, type: 'public-key', transports?: AuthenticatorTransportFuture[]}[] = existingPasskeys.map(pk => ({
|
||||
id: pk.credential_id,
|
||||
type: 'public-key',
|
||||
transports: pk.transports ? JSON.parse(pk.transports) as AuthenticatorTransportFuture[] : undefined,
|
||||
}));
|
||||
|
||||
const options: GenerateRegistrationOptionsOpts = {
|
||||
rpName: RP_NAME,
|
||||
rpID: RP_ID,
|
||||
userID: textEncoder.encode(userId.toString()),
|
||||
userName: username,
|
||||
userDisplayName: username,
|
||||
timeout: 60000,
|
||||
attestationType: 'none',
|
||||
excludeCredentials,
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
supportedAlgorithmIDs: [-7, -257],
|
||||
};
|
||||
|
||||
const generatedOptions = await generateRegOptions(options);
|
||||
return generatedOptions;
|
||||
}
|
||||
|
||||
async verifyRegistration(
|
||||
registrationResponseJSON: RegistrationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
userHandleFromClient: string
|
||||
): Promise<VerifiedRegistrationResponse & { newPasskeyToSave?: NewPasskey }> {
|
||||
const userId = parseInt(userHandleFromClient, 10);
|
||||
if (isNaN(userId)) {
|
||||
throw new Error('Invalid user handle provided.');
|
||||
}
|
||||
const user = await this.userRepo.findUserById(userId);
|
||||
if (!user) {
|
||||
throw new Error('User not found for the provided handle.');
|
||||
}
|
||||
|
||||
const verifyOpts: VerifyRegistrationResponseOpts = {
|
||||
response: registrationResponseJSON,
|
||||
expectedChallenge,
|
||||
expectedOrigin: RP_ORIGIN,
|
||||
expectedRPID: RP_ID,
|
||||
requireUserVerification: true,
|
||||
};
|
||||
|
||||
const verification = await verifyRegistrationResponse(verifyOpts);
|
||||
|
||||
if (verification.verified && verification.registrationInfo) {
|
||||
const regInfo = verification.registrationInfo;
|
||||
|
||||
// Assuming regInfo has these properties based on standard WebAuthn structures.
|
||||
// If these are incorrect for @simplewebauthn/server@13.1.1, this needs adjustment.
|
||||
const credentialPublicKey = (regInfo as any).credentialPublicKey;
|
||||
const credentialID = (regInfo as any).credentialID;
|
||||
const counter = (regInfo as any).counter;
|
||||
const transports = (regInfo as any).transports;
|
||||
const credentialBackedUp = (regInfo as any).credentialBackedUp;
|
||||
|
||||
if (!credentialPublicKey || typeof credentialID !== 'string' || typeof counter !== 'number') {
|
||||
console.error('Verification successful, but registrationInfo structure is unexpected:', regInfo);
|
||||
throw new Error('Failed to process registration info due to unexpected structure.');
|
||||
}
|
||||
|
||||
const publicKeyBase64 = Buffer.from(credentialPublicKey).toString('base64');
|
||||
|
||||
const newPasskeyEntry: NewPasskey = {
|
||||
user_id: user.id,
|
||||
credential_id: credentialID,
|
||||
public_key: publicKeyBase64,
|
||||
counter: counter,
|
||||
transports: transports ? JSON.stringify(transports) : null,
|
||||
backed_up: !!credentialBackedUp,
|
||||
};
|
||||
return { ...verification, newPasskeyToSave: newPasskeyEntry };
|
||||
}
|
||||
return verification;
|
||||
}
|
||||
|
||||
async generateAuthenticationOptions(username?: string) {
|
||||
let allowCredentials: {id: string, type: 'public-key', transports?: AuthenticatorTransportFuture[]}[] | undefined = undefined;
|
||||
|
||||
if (username) {
|
||||
const user = await this.userRepo.findUserByUsername(username);
|
||||
if (user) {
|
||||
const userPasskeys = await this.passkeyRepo.getPasskeysByUserId(user.id);
|
||||
allowCredentials = userPasskeys.map(pk => ({
|
||||
id: pk.credential_id,
|
||||
type: 'public-key',
|
||||
transports: pk.transports ? JSON.parse(pk.transports) as AuthenticatorTransportFuture[] : undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const options: GenerateAuthenticationOptionsOpts = {
|
||||
rpID: RP_ID,
|
||||
timeout: 60000,
|
||||
allowCredentials,
|
||||
userVerification: 'preferred',
|
||||
};
|
||||
|
||||
const generatedOptions = await generateAuthOptions(options);
|
||||
return generatedOptions;
|
||||
}
|
||||
|
||||
async verifyAuthentication(
|
||||
authenticationResponseJSON: AuthenticationResponseJSON,
|
||||
expectedChallenge: string
|
||||
): Promise<VerifiedAuthenticationResponse & { passkey?: Passkey, userId?: number }> {
|
||||
|
||||
const credentialIdFromResponse = authenticationResponseJSON.id;
|
||||
if (!credentialIdFromResponse) {
|
||||
throw new Error('Credential ID missing from authentication response.');
|
||||
}
|
||||
|
||||
const passkey = await this.passkeyRepo.getPasskeyByCredentialId(credentialIdFromResponse);
|
||||
if (!passkey) {
|
||||
throw new Error('Authentication failed. Passkey not found.');
|
||||
}
|
||||
|
||||
// TODO: Re-evaluate the structure of VerifyAuthenticationResponseOpts for @simplewebauthn/server@13.1.1
|
||||
// The 'authenticator' field seems to be causing type errors.
|
||||
const verifyOpts: any = { // Using 'any' temporarily to bypass the authenticator structure error
|
||||
response: authenticationResponseJSON,
|
||||
expectedChallenge,
|
||||
expectedOrigin: RP_ORIGIN,
|
||||
expectedRPID: RP_ID,
|
||||
authenticator: {
|
||||
credentialID: base64UrlToUint8Array(passkey.credential_id),
|
||||
credentialPublicKey: Buffer.from(passkey.public_key, 'base64'),
|
||||
counter: passkey.counter,
|
||||
transports: passkey.transports ? JSON.parse(passkey.transports) as AuthenticatorTransportFuture[] : undefined,
|
||||
},
|
||||
requireUserVerification: true,
|
||||
};
|
||||
|
||||
const verification = await verifyAuthenticationResponse(verifyOpts as VerifyAuthenticationResponseOpts);
|
||||
|
||||
if (verification.verified && verification.authenticationInfo) {
|
||||
const authInfo = verification.authenticationInfo;
|
||||
await this.passkeyRepo.updatePasskeyCounter(passkey.credential_id, authInfo.newCounter);
|
||||
await this.passkeyRepo.updatePasskeyLastUsedAt(passkey.credential_id);
|
||||
return { ...verification, passkey, userId: passkey.user_id };
|
||||
}
|
||||
throw new Error('Authentication failed.');
|
||||
}
|
||||
|
||||
async listPasskeysByUserId(userId: number): Promise<Partial<Passkey>[]> {
|
||||
const passkeys = await this.passkeyRepo.getPasskeysByUserId(userId);
|
||||
// 只返回部分信息以避免泄露敏感数据
|
||||
return passkeys.map(pk => ({
|
||||
credential_id: pk.credential_id,
|
||||
created_at: pk.created_at,
|
||||
last_used_at: pk.last_used_at,
|
||||
transports: pk.transports ? JSON.parse(pk.transports) : undefined,
|
||||
// 可以考虑添加一个用户可定义的名称字段
|
||||
}));
|
||||
}
|
||||
|
||||
async deletePasskey(userId: number, credentialID: string): Promise<boolean> {
|
||||
const passkey = await this.passkeyRepo.getPasskeyByCredentialId(credentialID);
|
||||
if (!passkey) {
|
||||
throw new Error('Passkey not found.');
|
||||
}
|
||||
if (passkey.user_id !== userId) {
|
||||
// 安全措施:用户只能删除自己的 Passkey
|
||||
throw new Error('Unauthorized to delete this passkey.');
|
||||
}
|
||||
const wasDeleted = await this.passkeyRepo.deletePasskey(credentialID);
|
||||
return wasDeleted;
|
||||
}
|
||||
}
|
||||
|
||||
export const passkeyService = new PasskeyService(passkeyRepository, userRepository);
|
||||
@@ -7,7 +7,12 @@ export type AuditLogActionType =
|
||||
| 'PASSWORD_CHANGED'
|
||||
| '2FA_ENABLED'
|
||||
| '2FA_DISABLED'
|
||||
// Removed Passkey events
|
||||
// Passkey Events
|
||||
| 'PASSKEY_REGISTERED'
|
||||
| 'PASSKEY_AUTH_SUCCESS'
|
||||
| 'PASSKEY_AUTH_FAILURE'
|
||||
| 'PASSKEY_DELETED'
|
||||
| 'PASSKEY_DELETE_UNAUTHORIZED'
|
||||
|
||||
// Connections
|
||||
| 'CONNECTION_CREATED'
|
||||
|
||||
@@ -3,7 +3,12 @@ export type NotificationChannelType = 'webhook' | 'email' | 'telegram';
|
||||
// Align NotificationEvent with AuditLogActionType as requested
|
||||
export type NotificationEvent =
|
||||
| 'LOGIN_SUCCESS' | 'LOGIN_FAILURE' | 'LOGOUT' | 'PASSWORD_CHANGED'
|
||||
| '2FA_ENABLED' | '2FA_DISABLED' // Removed Passkey events
|
||||
| '2FA_ENABLED' | '2FA_DISABLED'
|
||||
// Passkey Events
|
||||
| 'PASSKEY_REGISTERED'
|
||||
| 'PASSKEY_AUTH_SUCCESS' // Could also use LOGIN_SUCCESS with a 'method: passkey' detail
|
||||
| 'PASSKEY_AUTH_FAILURE'
|
||||
| 'PASSKEY_DELETED'
|
||||
| 'CONNECTION_CREATED' | 'CONNECTION_UPDATED' | 'CONNECTION_DELETED'
|
||||
| 'PROXY_CREATED' | 'PROXY_UPDATED' | 'PROXY_DELETED'
|
||||
| 'TAG_CREATED' | 'TAG_UPDATED' | 'TAG_DELETED'
|
||||
|
||||
Reference in New Issue
Block a user