From cc3b4d49309041c14efadab7f49762e81a2a457f Mon Sep 17 00:00:00 2001 From: tone Date: Thu, 18 Dec 2025 15:59:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=80=9A=E8=A1=8C?= =?UTF-8?q?=E8=AF=81=E6=B3=A8=E5=86=8C=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/auth/auth.controller.ts | 92 ++++++- apps/backend/src/auth/auth.module.ts | 5 +- .../backend/src/auth/dto/passkey-login.dto.ts | 3 + .../src/auth/dto/passkey-register.dto.ts | 7 + .../src/auth/service/passkey.service.ts | 244 ++++++++++++++++++ 5 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 apps/backend/src/auth/dto/passkey-login.dto.ts create mode 100644 apps/backend/src/auth/dto/passkey-register.dto.ts create mode 100644 apps/backend/src/auth/service/passkey.service.ts diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index 7616a45..bdd71d4 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -2,9 +2,8 @@ import { BadRequestException, Body, Controller, - NotImplementedException, Post, - Request, + Req, Res, UseGuards, } from '@nestjs/common'; @@ -12,12 +11,17 @@ import { LoginByPasswordDto } from './dto/login.dto'; import { AuthService } from './auth.service'; import { UserSessionService } from 'src/auth/service/user-session.service'; import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; -import { Response } from 'express'; +import { Request, Response } from 'express'; import { UserService } from 'src/user/user.service'; import { AuthGuard } from './guards/auth.guard'; import { SmsLoginDto } from './dto/sms-login.dto'; import { SmsService } from 'src/sms/sms.service'; import { UserSession } from 'src/auth/entity/user-session.entity'; +import { PasskeyService } from './service/passkey.service'; +import { v4 as uuidv4 } from 'uuid'; +import { PasskeyLoginDto } from './dto/passkey-login.dto'; +import { AuthUser, CurrentUser } from './decorator/current-user.decorator'; +import { PasskeyRegisterDto } from './dto/passkey-register.dto'; @Controller('auth') export class AuthController { @@ -26,6 +30,7 @@ export class AuthController { private readonly userService: UserService, private readonly userSessionService: UserSessionService, private readonly smsService: SmsService, + private readonly passkeyService: PasskeyService, ) { } private setUserSession(res: Response, session: UserSession) { @@ -66,22 +71,89 @@ export class AuthController { } } + @Post('passkey/login/options') - async loginByPasskeyOptions() { - throw new NotImplementedException(); + async loginByPasskeyOptions( + @Res({ passthrough: true }) res: Response, + ) { + const tempSessionId = uuidv4(); + const options = await this.passkeyService.getAuthenticationOptions(tempSessionId); + + res.cookie('passkey_temp_session', tempSessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/auth/passkey/login', + maxAge: 1 * 60 * 1000, + }); + return options; } @Post('passkey/login') - async loginByPasskey() { - throw new NotImplementedException(); + async loginByPasskey( + @Req() req: Request, + @Body() body: PasskeyLoginDto, + @Res({ passthrough: true }) res: Response, + ) { + const tempSessionId = req.cookies?.passkey_temp_session; + if (!tempSessionId) { + throw new BadRequestException('登录失败,请重试'); + } + + try { + const user = await this.passkeyService.login(tempSessionId, body.credentialResponse); + + const session = await this.userSessionService.createSession(user.userId); + + this.setUserSession(res, session); + + return { + user: await this.userService.findById(user.userId), + }; + } catch (error) { + throw error; + } finally { + res.clearCookie('passkey_temp_session', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/auth/passkey/login', + }); + } + } + + @UseGuards(AuthGuard) + @Post('passkey/register/options') + async getPasskeyRegisterOptions( + @CurrentUser() user: AuthUser, + ) { + const { userId } = user; + return this.passkeyService.getRegistrationOptions(userId); + } + + @UseGuards(AuthGuard) + @Post('passkey/register') + async registerPasskey( + @CurrentUser() user: AuthUser, + @Body() dto: PasskeyRegisterDto, + ) { + const { userId } = user; + const { credentialResponse, name } = dto; + + const passkey = await this.passkeyService.register(userId, credentialResponse, name.trim()); + + return { + id: passkey.id, + name: passkey.name, + createdAt: passkey.createdAt, + }; } @UseGuards(AuthGuard) @Post('logout') - async logout(@Request() req) { - const { userId, sessionId } = req.user; + async logout(@CurrentUser() user: AuthUser) { + const { userId, sessionId } = user; await this.userSessionService.invalidateSession(userId, sessionId); - return true; } } diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index 71cc256..64848eb 100644 --- a/apps/backend/src/auth/auth.module.ts +++ b/apps/backend/src/auth/auth.module.ts @@ -11,6 +11,7 @@ import { OptionalAuthGuard } from './guards/optional-auth.guard'; import { SmsModule } from 'src/sms/sms.module'; import { PasskeyCredential } from './entity/passkey-credential.entity'; import { UserSessionService } from './service/user-session.service'; +import { PasskeyService } from './service/passkey.service'; @Module({ imports: [ @@ -21,7 +22,7 @@ import { UserSessionService } from './service/user-session.service'; SmsModule, ], controllers: [AuthController], - providers: [AuthService, AuthGuard, OptionalAuthGuard, UserSessionService], - exports: [AuthService, AuthGuard, OptionalAuthGuard, UserSessionService], + providers: [AuthService, UserSessionService, PasskeyService, AuthGuard, OptionalAuthGuard], + exports: [AuthService, UserSessionService, PasskeyService, AuthGuard, OptionalAuthGuard], }) export class AuthModule { } diff --git a/apps/backend/src/auth/dto/passkey-login.dto.ts b/apps/backend/src/auth/dto/passkey-login.dto.ts new file mode 100644 index 0000000..223164f --- /dev/null +++ b/apps/backend/src/auth/dto/passkey-login.dto.ts @@ -0,0 +1,3 @@ +export class PasskeyLoginDto { + credentialResponse: any; +} \ No newline at end of file diff --git a/apps/backend/src/auth/dto/passkey-register.dto.ts b/apps/backend/src/auth/dto/passkey-register.dto.ts new file mode 100644 index 0000000..95869bb --- /dev/null +++ b/apps/backend/src/auth/dto/passkey-register.dto.ts @@ -0,0 +1,7 @@ +import { IsString } from "class-validator"; + +export class PasskeyRegisterDto { + credentialResponse: any; + @IsString({ message: '通行证名称只能是字符串' }) + name: string; +} \ No newline at end of file diff --git a/apps/backend/src/auth/service/passkey.service.ts b/apps/backend/src/auth/service/passkey.service.ts new file mode 100644 index 0000000..1bf5f4f --- /dev/null +++ b/apps/backend/src/auth/service/passkey.service.ts @@ -0,0 +1,244 @@ +import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { PasskeyCredential } from "../entity/passkey-credential.entity"; +import { Repository } from "typeorm"; +import { User } from "src/user/entities/user.entity"; +import crypto from 'crypto'; +import { generateAuthenticationOptions, GenerateAuthenticationOptionsOpts, generateRegistrationOptions, GenerateRegistrationOptionsOpts, VerifiedAuthenticationResponse, VerifiedRegistrationResponse, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server"; + + +interface ChallengeEntry { + value: string; + expiresAt: number; +} + +class MemoryChallengeStore { + private store = new Map(); + private cleanupInterval: NodeJS.Timeout | null = null; + + constructor(private ttlMs: number = 5 * 60 * 100) { + this.startCleanup(); + } + + set(key: string, value: string): void { + this.store.set(key, { + value, + expiresAt: Date.now() + this.ttlMs, + }); + } + + get(key: string): string | null { + const entry = this.store.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return null; + } + return entry.value; + } + + delete(key: string): void { + this.store.delete(key); + } + + private startCleanup(): void { + this.cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of this.store.entries()) { + if (now > entry.expiresAt) { + this.store.delete(key); + } + } + }, 60_000); // 每分钟清理一次 + } + + stopCleanup(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } +} + +const registrationChallenges = new MemoryChallengeStore(5 * 60 * 1000); // 5 分钟过期 +const authenticationChallenges = new MemoryChallengeStore(5 * 60 * 1000); + + +@Injectable() +export class PasskeyService implements OnModuleDestroy { + + private readonly rpID: string; + private readonly origin: string; + private readonly rpName: string; + + constructor( + @InjectRepository(PasskeyCredential) + private readonly passkeyRepo: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + ) { + this.rpID = process.env.WEBAUTHN_RP_ID; + this.origin = process.env.WEBAUTHN_ORIGIN; + this.rpName = process.env.WEBAUTHN_RP_NAME; + + if (!this.rpID || !this.origin || !this.rpName) { + throw new Error('Missing required env: WEBAUTHN_RP_ID or WEBAUTHN_ORIGIN'); + } + } + + onModuleDestroy() { + registrationChallenges.stopCleanup(); + authenticationChallenges.stopCleanup(); + } + + async getRegistrationOptions(userId: string) { + const user = await this.userRepository.findOneBy({ userId }); + if (!user) { + throw new NotFoundException('用户不存在'); + } + + const challenge = crypto.randomBytes(32).toString('base64url'); + registrationChallenges.set(userId, challenge); + + const opts: GenerateRegistrationOptionsOpts = { + rpName: this.rpName, + rpID: this.rpID, + userID: Buffer.from(userId), + userName: user.username || 'user', + userDisplayName: user.nickname || 'User', + challenge, + authenticatorSelection: { + residentKey: 'required', // 必须是可发现凭证(Passkey) + userVerification: 'preferred', + }, + supportedAlgorithmIDs: [-7], // ES256 + timeout: 60000, + }; + + return generateRegistrationOptions(opts); + } + + async register(userId: string, credentialResponse: any, name: string): Promise { + const expectedChallenge = registrationChallenges.get(userId); + if (!expectedChallenge) { + throw new BadRequestException('注册失败,请重试'); + } + + let verification: VerifiedRegistrationResponse; + try { + verification = await verifyRegistrationResponse({ + response: credentialResponse, + expectedChallenge, + expectedOrigin: this.origin, + expectedRPID: this.rpID, + requireUserVerification: false, + }); + } catch (err) { + throw new BadRequestException('注册失败'); + } + + if (!verification.verified) { + throw new BadRequestException('注册失败'); + } + + const { credential } = verification.registrationInfo; + if (!credential) { + throw new InternalServerErrorException('服务器内部错误'); + } + + // 保存凭证到数据库 + const passkey = this.passkeyRepo.create({ + user: { userId } as User, + name: name || '新的通行证', + credentialId: credential.id, + publicKey: credential.publicKey.toString(), + signCount: credential.counter, + verified: true, + }); + + await this.passkeyRepo.save(passkey); + registrationChallenges.delete(userId); + + return passkey; + } + + async getAuthenticationOptions(sessionId: string) { + const challenge = crypto.randomBytes(32).toString('base64url'); + authenticationChallenges.set(sessionId, challenge); + + const opts: GenerateAuthenticationOptionsOpts = { + rpID: this.rpID, + challenge, + timeout: 60000, + userVerification: 'preferred', + }; + + return generateAuthenticationOptions(opts); + } + + async login(sessionId: string, credentialResponse: any): Promise { + const expectedChallenge = authenticationChallenges.get(sessionId); + if (!expectedChallenge) { + throw new BadRequestException('认证失败,请重试'); + } + + const credentialId = credentialResponse.id; + const passkey = await this.passkeyRepo.findOne({ + where: { credentialId, verified: true }, + relations: ['user'], + }); + + if (!passkey) { + throw new NotFoundException('未找到可用的通信证'); + } + + let verification: VerifiedAuthenticationResponse; + try { + verification = await verifyAuthenticationResponse({ + response: credentialResponse, + expectedChallenge, + expectedOrigin: this.origin, + expectedRPID: this.rpID, + credential: { + id: passkey.credentialId, + publicKey: Buffer.from(passkey.publicKey), + counter: passkey.signCount, + }, + requireUserVerification: false, + }); + } catch (err) { + throw new BadRequestException('认证失败'); + } + + if (!verification.verified) { + throw new BadRequestException('认证失败'); + } + + const newSignCount = verification.authenticationInfo.newCounter; + if (newSignCount !== passkey.signCount) { + passkey.signCount = newSignCount; + await this.passkeyRepo.save(passkey); + } + + authenticationChallenges.delete(sessionId); + return passkey.user; + } + + async listUserPasskeys(userId: string): Promise { + return this.passkeyRepo.find({ + where: { user: { userId }, verified: true }, + select: ['id', 'name', 'createdAt'], + }); + } + + async removePasskey(userId: string, passkeyId: string): Promise { + const result = await this.passkeyRepo.delete({ + id: passkeyId, + user: { userId }, + }); + + if (result.affected === 0) { + throw new NotFoundException('未找到对应的通行证'); + } + } +} \ No newline at end of file