This commit is contained in:
Baobhan Sith
2025-04-27 00:40:03 +08:00
parent 4043e297b0
commit 3fa03f260e
11 changed files with 553 additions and 32 deletions
+194 -2
View File
@@ -1,3 +1,4 @@
import { Request, Response } from 'express';
import bcrypt from 'bcrypt';
import { getDbInstance, runDb, getDb, allDb } from '../database/connection';
@@ -13,9 +14,9 @@ import { settingsService } from '../services/settings.service';
const passkeyService = new PasskeyService();
const notificationService = new NotificationService();
const auditLogService = new AuditLogService();
const auditLogService = new AuditLogService();
interface User {
export interface User { // Add export keyword
id: number;
username: string;
hashed_password: string;
@@ -476,7 +477,15 @@ export const verifyPasskeyRegistration = async (req: Request, res: Response): Pr
// 再次提醒: 确保 Express 配置了 'trust proxy'
// 从 session 获取 userId
const userId = req.session.userId;
if (!userId) {
// 这个检查理论上在函数开头已经做过,但为了类型安全和明确性再次检查
throw new Error('无法获取用户 ID,无法验证 Passkey。');
}
const verification = await passkeyService.verifyRegistration(
userId, // <-- 传递 userId 作为第一个参数
registrationResponse,
expectedChallenge,
hostname,
@@ -501,6 +510,189 @@ export const verifyPasskeyRegistration = async (req: Request, res: Response): Pr
res.status(500).json({ message: '验证 Passkey 注册失败。', error: error.message });
}
};
/**
* 获取当前用户已注册的所有 Passkey (GET /api/v1/auth/passkeys)
*/
export const listUserPasskeys = async (req: Request, res: Response): Promise<void> => {
const userId = req.session.userId;
if (!userId || req.session.requiresTwoFactor) {
res.status(401).json({ message: '用户未认证或认证未完成。' });
return;
}
try {
// 注意:PasskeyService 的 listPasskeys 目前是获取所有用户的,
// 实际应用中应该只获取当前用户的。这里暂时调用,
// 但 PasskeyRepository 和 Service 可能需要调整以支持按用户过滤。
// 假设 PasskeyRepository.getAllPasskeys() 可以接受 userId 过滤
// 或者 PasskeyService.listPasskeys() 内部处理过滤逻辑。
// **临时简化处理:** 假设 PasskeyService.listPasskeys() 返回所有密钥,
// 在实际应用中需要根据 userId 过滤。
// TODO: Refactor PasskeyRepository/Service to filter by userId
const passkeys = await passkeyService.listPasskeys(); // 假设这会返回适合前端展示的数据结构
res.status(200).json(passkeys);
} catch (error: any) {
console.error(`用户 ${userId} 获取 Passkey 列表时出错:`, error);
res.status(500).json({ message: '获取 Passkey 列表失败。', error: error.message });
}
};
/**
* 删除指定的 Passkey (DELETE /api/v1/auth/passkeys/:id)
*/
export const deleteUserPasskey = async (req: Request, res: Response): Promise<void> => {
const userId = req.session.userId;
const passkeyIdToDelete = parseInt(req.params.id, 10); // 从路由参数获取 ID
if (!userId || req.session.requiresTwoFactor) {
res.status(401).json({ message: '用户未认证或认证未完成。' });
return;
}
if (isNaN(passkeyIdToDelete)) {
res.status(400).json({ message: '无效的 Passkey ID。' });
return;
}
try {
// TODO: 在删除前,应该验证这个 Passkey ID 是否属于当前登录用户。
// 这需要调整 PasskeyRepository.deletePasskeyById 或增加一个验证步骤。
// **临时简化处理:** 直接尝试删除。
await passkeyService.deletePasskey(passkeyIdToDelete);
console.log(`用户 ${userId} 删除了 Passkey ID: ${passkeyIdToDelete}`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
// 记录审计日志
auditLogService.logAction('PASSKEY_DELETED', { userId, passkeyId: passkeyIdToDelete, ip: clientIp });
notificationService.sendNotification('PASSKEY_DELETED', { userId, passkeyId: passkeyIdToDelete, ip: clientIp });
res.status(200).json({ message: 'Passkey 删除成功。' });
} catch (error: any) {
console.error(`用户 ${userId} 删除 Passkey ID ${passkeyIdToDelete} 时出错:`, error);
// 可以根据错误类型返回不同状态码,例如找不到资源返回 404
if (error.message?.includes('未找到')) { // 简单的错误检查
res.status(404).json({ message: '未找到要删除的 Passkey。', error: error.message });
} else {
res.status(500).json({ message: '删除 Passkey 失败。', error: error.message });
}
}
};
/**
* 生成 Passkey 认证选项 (POST /api/v1/auth/passkey/authenticate-options)
* 用于登录流程
*/
export const generatePasskeyAuthenticationOptions = async (req: Request, res: Response): Promise<void> => {
try {
// 从请求中获取 hostname
const hostname = req.hostname;
// 确保 Express 配置了 'trust proxy'
const options = await passkeyService.generateAuthenticationOptions(hostname);
// 将 challenge 存储在 session 中,用于后续验证
req.session.currentChallenge = options.challenge;
// 可以在这里添加一个标记,表明正在进行 passkey 认证
// req.session.passkeyAuthInProgress = true;
console.log(`[AuthController] 为 Passkey 登录生成认证选项,Challenge: ${options.challenge}`);
res.json(options);
} catch (error: any) {
console.error(`生成 Passkey 认证选项时出错:`, error);
res.status(500).json({ message: '生成 Passkey 认证选项失败。', error: error.message });
}
};
/**
* 验证 Passkey 认证响应并登录 (POST /api/v1/auth/passkey/verify-authentication)
*/
export const verifyPasskeyAuthentication = async (req: Request, res: Response): Promise<void> => {
const expectedChallenge = req.session.currentChallenge;
const { authenticationResponse, rememberMe } = req.body; // 获取认证响应和 rememberMe 状态
if (!expectedChallenge) {
res.status(400).json({ message: '未找到预期的挑战,请重新开始登录流程。' });
return;
}
if (!authenticationResponse) {
res.status(400).json({ message: '缺少认证响应数据。' });
return;
}
// 清除 session 中的 challenge,无论成功与否
delete req.session.currentChallenge;
// delete req.session.passkeyAuthInProgress; // 清除标记
try {
// 从请求中获取 hostname 和 origin
const hostname = req.hostname;
const originHeader = req.get('origin');
const origin = originHeader || `${req.protocol}://${req.get('host')}`;
// 确保 Express 配置了 'trust proxy'
const verification = await passkeyService.verifyAuthentication(
authenticationResponse,
expectedChallenge,
hostname,
origin
);
if (verification.verified && verification.userInfo) {
const { userId, username } = verification.userInfo;
console.log(`Passkey 认证成功,用户: ${username} (ID: ${userId})`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
// --- 认证成功,建立会话 ---
ipBlacklistService.resetAttempts(clientIp); // 重置 IP 失败尝试
auditLogService.logAction('LOGIN_SUCCESS', { userId, username, ip: clientIp, method: 'passkey' });
notificationService.sendNotification('LOGIN_SUCCESS', { userId, username, ip: clientIp, method: 'passkey' });
req.session.userId = userId;
req.session.username = username;
req.session.requiresTwoFactor = false; // Passkey 本身包含验证,通常视为已完成 2FA
// 根据 rememberMe 设置 cookie maxAge
if (rememberMe) {
req.session.cookie.maxAge = 315360000000; // 10 years
} else {
req.session.cookie.maxAge = undefined; // Session cookie
}
res.status(200).json({
message: 'Passkey 登录成功。',
user: { id: userId, username: username }
});
// --- 会话建立结束 ---
} else {
console.error(`Passkey 认证验证失败:`, verification);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
ipBlacklistService.recordFailedAttempt(clientIp); // 记录失败尝试
// 尝试从响应中获取用户 ID (如果可能) 用于日志记录
const credentialId = authenticationResponse?.id;
let potentialUserId: number | string = 'unknown';
if (credentialId) {
try {
const authenticator = await passkeyService.getPasskeyByCredentialId(credentialId);
if (authenticator) potentialUserId = authenticator.user_id;
} catch { /* ignore error */ }
}
auditLogService.logAction('LOGIN_FAILURE', { userId: potentialUserId, reason: 'Passkey verification failed', ip: clientIp, method: 'passkey' });
notificationService.sendNotification('LOGIN_FAILURE', { userId: potentialUserId, reason: 'Passkey verification failed', ip: clientIp, method: 'passkey' });
res.status(401).json({ message: 'Passkey 认证失败。', verified: false });
}
} catch (error: any) {
console.error(`验证 Passkey 认证时出错:`, error);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
ipBlacklistService.recordFailedAttempt(clientIp); // 记录失败尝试
auditLogService.logAction('LOGIN_FAILURE', { reason: `Passkey verification error: ${error.message}`, ip: clientIp, method: 'passkey' });
notificationService.sendNotification('LOGIN_FAILURE', { reason: `Passkey verification error: ${error.message}`, ip: clientIp, method: 'passkey' });
res.status(500).json({ message: '验证 Passkey 认证失败。', error: error.message });
}
};
/**
* 验证并激活 2FA (POST /api/v1/auth/2fa/verify)
+21 -6
View File
@@ -6,13 +6,17 @@ import {
setup2FA,
verifyAndActivate2FA,
disable2FA,
getAuthStatus,
generatePasskeyRegistrationOptions,
getAuthStatus,
generatePasskeyRegistrationOptions,
verifyPasskeyRegistration,
needsSetup,
setupAdmin,
logout,
getPublicCaptchaConfig
generatePasskeyAuthenticationOptions, // <-- 添加导入
verifyPasskeyAuthentication, // <-- 添加导入
listUserPasskeys,
deleteUserPasskey,
needsSetup,
setupAdmin,
logout,
getPublicCaptchaConfig
} from './auth.controller';
import { isAuthenticated } from './auth.middleware';
import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware';
@@ -59,7 +63,18 @@ router.post('/passkey/register-options', isAuthenticated, generatePasskeyRegistr
// POST /api/v1/auth/passkey/verify-registration - 验证 Passkey 注册响应
router.post('/passkey/verify-registration', isAuthenticated, verifyPasskeyRegistration);
// GET /api/v1/auth/passkeys - 获取当前用户的所有 Passkey
router.get('/passkeys', isAuthenticated, listUserPasskeys);
// DELETE /api/v1/auth/passkeys/:id - 删除指定的 Passkey
router.delete('/passkeys/:id', isAuthenticated, deleteUserPasskey);
// --- Passkey 认证接口 (公开访问,添加黑名单检查) ---
// POST /api/v1/auth/passkey/authenticate-options - 生成 Passkey 认证选项 (用于登录)
router.post('/passkey/authenticate-options', ipBlacklistCheckMiddleware, generatePasskeyAuthenticationOptions);
// POST /api/v1/auth/passkey/verify-authentication - 验证 Passkey 认证响应并登录
router.post('/passkey/verify-authentication', ipBlacklistCheckMiddleware, verifyPasskeyAuthentication);
// POST /api/v1/auth/logout - 用户登出接口 (公开访问)
router.post('/logout', logout);
+3 -1
View File
@@ -31,13 +31,15 @@ CREATE TABLE IF NOT EXISTS audit_logs (
export const createPasskeysTableSQL = `
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, -- 新增:关联到用户 ID
credential_id TEXT UNIQUE NOT NULL, -- Base64URL encoded
public_key TEXT NOT NULL, -- Base64URL encoded
counter INTEGER NOT NULL,
transports TEXT, -- JSON array as string, e.g., '["internal", "usb"]'
name TEXT, -- User-provided name for the key
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_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 -- 新增:外键约束
);
`;
@@ -3,6 +3,7 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/conn
// 定义 Passkey 数据库记录的接口
export interface PasskeyRecord {
id: number;
user_id: number; // 新增:关联的用户 ID
credential_id: string; // Base64URL encoded
public_key: string; // Base64URL encoded
counter: number;
@@ -13,15 +14,17 @@ export interface PasskeyRecord {
}
type DbPasskeyRow = PasskeyRecord;
type DbPasskeyRow = PasskeyRecord; // 类型别名保持不变,因为接口已更新
export class PasskeyRepository {
/**
* 保存新的 Passkey 凭证
* @param userId 关联的用户 ID
* @returns Promise<number> 新插入记录的 ID
*/
async savePasskey(
userId: number, // 新增 userId 参数
credentialId: string,
publicKey: string,
counter: number,
@@ -29,10 +32,11 @@ export class PasskeyRepository {
name?: string
): Promise<number> {
const sql = `
INSERT INTO passkeys (credential_id, public_key, counter, transports, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
INSERT INTO passkeys (user_id, credential_id, public_key, counter, transports, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
`;
const params = [credentialId, publicKey, counter, transports, name ?? null];
// 新增 userId 到参数列表
const params = [userId, credentialId, publicKey, counter, transports, name ?? null];
try {
const db = await getDbInstance();
const result = await runDb(db, sql, params);
@@ -46,18 +50,24 @@ export class PasskeyRepository {
if (err.message.includes('UNIQUE constraint failed')) {
throw new Error(`Credential ID "${credentialId}" 已存在。`);
}
// 检查外键约束错误
if (err.message.includes('FOREIGN KEY constraint failed')) {
throw new Error(`关联的用户 ID ${userId} 不存在。`);
}
throw new Error(`保存 Passkey 时出错: ${err.message}`);
}
}
/**
* 根据 Credential ID 获取 Passkey 记录
* 根据 Credential ID 获取 Passkey 记录 (包含 user_id)
* @returns Promise<PasskeyRecord | null> 找到的记录或 null
*/
async getPasskeyByCredentialId(credentialId: string): Promise<PasskeyRecord | null> {
// 确保查询包含 user_id
const sql = `SELECT * FROM passkeys WHERE credential_id = ?`;
try {
const db = await getDbInstance();
// 使用更新后的 DbPasskeyRow 类型
const row = await getDbRow<DbPasskeyRow>(db, sql, [credentialId]);
return row || null;
} catch (err: any) {
@@ -67,18 +77,27 @@ export class PasskeyRepository {
}
/**
* 获取所有已注册的 Passkey 记录 (仅选择必要字段)
* @returns Promise<Partial<PasskeyRecord>[]> 所有记录的部分信息的数组
* 获取指定用户或所有已注册的 Passkey 记录 (仅选择必要字段)
* @param userId 可选,如果提供,则只获取该用户的 Passkey
* @returns Promise<Partial<PasskeyRecord>[]> 记录的部分信息的数组
*/
async getAllPasskeys(): Promise<Array<Pick<PasskeyRecord, 'id' | 'credential_id' | 'name' | 'transports' | 'created_at'>>> {
const sql = `SELECT id, credential_id, name, transports, created_at FROM passkeys ORDER BY created_at DESC`;
async getAllPasskeys(userId?: number): Promise<Array<Pick<PasskeyRecord, 'id' | 'user_id' | 'credential_id' | 'name' | 'transports' | 'created_at'>>> {
let sql = `SELECT id, user_id, credential_id, name, transports, created_at FROM passkeys`;
const params: any[] = [];
if (userId !== undefined) {
sql += ` WHERE user_id = ?`;
params.push(userId);
}
sql += ` ORDER BY created_at DESC`;
try {
const db = await getDbInstance();
const rows = await allDb<Pick<PasskeyRecord, 'id' | 'credential_id' | 'name' | 'transports' | 'created_at'>>(db, sql);
// 更新返回类型以包含 user_id
const rows = await allDb<Pick<PasskeyRecord, 'id' | 'user_id' | 'credential_id' | 'name' | 'transports' | 'created_at'>>(db, sql, params);
return rows;
} catch (err: any) {
console.error('获取所有 Passkey 时出错:', err.message);
throw new Error(`获取所有 Passkey 时出错: ${err.message}`);
console.error('获取 Passkey 列表时出错:', err.message);
throw new Error(`获取 Passkey 列表时出错: ${err.message}`);
}
}
@@ -4,8 +4,9 @@ import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
VerifiedRegistrationResponse,
VerifiedAuthenticationResponse,
// VerifiedAuthenticationResponse, // Remove original import
} from '@simplewebauthn/server';
import type { VerifiedAuthenticationResponse as SimpleVerifiedAuthenticationResponse } from '@simplewebauthn/server'; // Import with alias
import type {
GenerateRegistrationOptionsOpts,
GenerateAuthenticationOptionsOpts,
@@ -13,10 +14,18 @@ import type {
VerifyAuthenticationResponseOpts,
RegistrationResponseJSON,
AuthenticationResponseJSON,
} from '@simplewebauthn/server';
} from '@simplewebauthn/server';
import { PasskeyRepository, PasskeyRecord } from '../repositories/passkey.repository';
import { getDbInstance, getDb } from '../database/connection'; // Import database functions
import type { User } from '../auth/auth.controller'; // Import User type (assuming it's defined or importable from auth.controller)
// Define extended verification response type including user info
export interface VerifiedAuthenticationResponse extends SimpleVerifiedAuthenticationResponse {
userInfo?: {
userId: number;
username: string;
};
}
// 定义 Relying Party (RP) 信息 - 这些应该来自配置或设置
const rpName = 'Nexus Terminal';
// rpID 和 expectedOrigin 将从请求动态获取,不再在此处硬编码
@@ -63,6 +72,7 @@ export class PasskeyService {
/**
* 验证 Passkey 注册响应
* @param userId 当前登录用户的 ID
* @param registrationResponse 来自客户端的注册响应
* @param expectedChallenge 之前生成的、临时存储的挑战
* @param hostname 请求的主机名
@@ -70,6 +80,7 @@ export class PasskeyService {
* @param passkeyName 用户为这个 Passkey 起的名字 (可选)
*/
async verifyRegistration(
userId: number, // 新增 userId 参数
registrationResponse: RegistrationResponseJSON,
expectedChallenge: string,
hostname: string,
@@ -110,15 +121,16 @@ export class PasskeyService {
// 获取 transports 信息
const transports = registrationResponse.response.transports ?? null;
// 保存到数据库
// 保存到数据库,传入 userId
await this.passkeyRepository.savePasskey(
userId, // 传递 userId
credentialIdBase64Url,
publicKeyBase64Url,
counter,
transports ? JSON.stringify(transports) : null,
passkeyName
);
console.log(`Passkey 注册成功: ${credentialIdBase64Url}, Name: ${passkeyName ?? 'N/A'}`);
console.log(`用户 ${userId} Passkey 注册成功: ${credentialIdBase64Url}, Name: ${passkeyName ?? 'N/A'}`);
} else {
console.error('Passkey 注册验证失败:', verification);
}
@@ -160,7 +172,7 @@ export class PasskeyService {
expectedChallenge: string,
hostname: string,
origin: string
): Promise<VerifiedAuthenticationResponse> {
): Promise<VerifiedAuthenticationResponse> { // Return our extended type
const credentialIdBase64Url = authenticationResponse.id; // 客户端传回的 ID 已经是 Base64URL
const authenticator = await this.passkeyRepository.getPasskeyByCredentialId(credentialIdBase64Url);
@@ -204,6 +216,23 @@ export class PasskeyService {
// 更新数据库中的计数器
await this.passkeyRepository.updatePasskeyCounter(authenticator.credential_id, newCounter);
console.log(`Passkey 认证成功: ${authenticator.credential_id}`);
// --- Added: Fetch user information ---
const db = await getDbInstance();
// Assuming PasskeyRecord has user_id
const user = await getDb<User>(db, 'SELECT id, username FROM users WHERE id = ?', [authenticator.user_id]);
if (!user) {
// This theoretically shouldn't happen if the authenticator exists
console.error(`Passkey authentication successful but associated user not found: UserID ${authenticator.user_id}, CredentialID ${authenticator.credential_id}`);
throw new Error('Passkey authentication successful but failed to find associated user information.');
}
// Attach user info to the verification result
(verification as VerifiedAuthenticationResponse).userInfo = {
userId: user.id,
username: user.username,
};
// --- End: Fetch user information ---
} else {
console.error('Passkey 认证验证失败:', verification);
}
@@ -232,4 +261,14 @@ export class PasskeyService {
async deletePasskey(id: number): Promise<void> {
await this.passkeyRepository.deletePasskeyById(id);
}
/**
* 根据 Credential ID 获取 Passkey 记录 (供认证验证使用)
* @param credentialIdBase64Url Base64URL 编码的 Credential ID
*/
async getPasskeyByCredentialId(credentialIdBase64Url: string): Promise<PasskeyRecord | null> {
// 注意:PasskeyRepository 需要有 getPasskeyByCredentialId 方法
// 并且 PasskeyRecord 需要包含 user_id 以便后续查找用户
return this.passkeyRepository.getPasskeyByCredentialId(credentialIdBase64Url);
}
}