feat: 实现通行证注册登录
This commit is contained in:
@@ -2,9 +2,8 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
NotImplementedException,
|
|
||||||
Post,
|
Post,
|
||||||
Request,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -12,12 +11,17 @@ import { LoginByPasswordDto } from './dto/login.dto';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { UserSessionService } from 'src/auth/service/user-session.service';
|
import { UserSessionService } from 'src/auth/service/user-session.service';
|
||||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
import { Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { UserService } from 'src/user/user.service';
|
import { UserService } from 'src/user/user.service';
|
||||||
import { AuthGuard } from './guards/auth.guard';
|
import { AuthGuard } from './guards/auth.guard';
|
||||||
import { SmsLoginDto } from './dto/sms-login.dto';
|
import { SmsLoginDto } from './dto/sms-login.dto';
|
||||||
import { SmsService } from 'src/sms/sms.service';
|
import { SmsService } from 'src/sms/sms.service';
|
||||||
import { UserSession } from 'src/auth/entity/user-session.entity';
|
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')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -26,6 +30,7 @@ export class AuthController {
|
|||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly userSessionService: UserSessionService,
|
private readonly userSessionService: UserSessionService,
|
||||||
private readonly smsService: SmsService,
|
private readonly smsService: SmsService,
|
||||||
|
private readonly passkeyService: PasskeyService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
private setUserSession(res: Response, session: UserSession) {
|
private setUserSession(res: Response, session: UserSession) {
|
||||||
@@ -66,22 +71,89 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Post('passkey/login/options')
|
@Post('passkey/login/options')
|
||||||
async loginByPasskeyOptions() {
|
async loginByPasskeyOptions(
|
||||||
throw new NotImplementedException();
|
@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')
|
@Post('passkey/login')
|
||||||
async loginByPasskey() {
|
async loginByPasskey(
|
||||||
throw new NotImplementedException();
|
@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)
|
@UseGuards(AuthGuard)
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
async logout(@Request() req) {
|
async logout(@CurrentUser() user: AuthUser) {
|
||||||
const { userId, sessionId } = req.user;
|
const { userId, sessionId } = user;
|
||||||
await this.userSessionService.invalidateSession(userId, sessionId);
|
await this.userSessionService.invalidateSession(userId, sessionId);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { OptionalAuthGuard } from './guards/optional-auth.guard';
|
|||||||
import { SmsModule } from 'src/sms/sms.module';
|
import { SmsModule } from 'src/sms/sms.module';
|
||||||
import { PasskeyCredential } from './entity/passkey-credential.entity';
|
import { PasskeyCredential } from './entity/passkey-credential.entity';
|
||||||
import { UserSessionService } from './service/user-session.service';
|
import { UserSessionService } from './service/user-session.service';
|
||||||
|
import { PasskeyService } from './service/passkey.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -21,7 +22,7 @@ import { UserSessionService } from './service/user-session.service';
|
|||||||
SmsModule,
|
SmsModule,
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, AuthGuard, OptionalAuthGuard, UserSessionService],
|
providers: [AuthService, UserSessionService, PasskeyService, AuthGuard, OptionalAuthGuard],
|
||||||
exports: [AuthService, AuthGuard, OptionalAuthGuard, UserSessionService],
|
exports: [AuthService, UserSessionService, PasskeyService, AuthGuard, OptionalAuthGuard],
|
||||||
})
|
})
|
||||||
export class AuthModule { }
|
export class AuthModule { }
|
||||||
|
|||||||
3
apps/backend/src/auth/dto/passkey-login.dto.ts
Normal file
3
apps/backend/src/auth/dto/passkey-login.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export class PasskeyLoginDto {
|
||||||
|
credentialResponse: any;
|
||||||
|
}
|
||||||
7
apps/backend/src/auth/dto/passkey-register.dto.ts
Normal file
7
apps/backend/src/auth/dto/passkey-register.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class PasskeyRegisterDto {
|
||||||
|
credentialResponse: any;
|
||||||
|
@IsString({ message: '通行证名称只能是字符串' })
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
244
apps/backend/src/auth/service/passkey.service.ts
Normal file
244
apps/backend/src/auth/service/passkey.service.ts
Normal file
@@ -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<string, ChallengeEntry>();
|
||||||
|
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<PasskeyCredential>,
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
) {
|
||||||
|
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<PasskeyCredential> {
|
||||||
|
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<User> {
|
||||||
|
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<PasskeyCredential[]> {
|
||||||
|
return this.passkeyRepo.find({
|
||||||
|
where: { user: { userId }, verified: true },
|
||||||
|
select: ['id', 'name', 'createdAt'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePasskey(userId: string, passkeyId: string): Promise<void> {
|
||||||
|
const result = await this.passkeyRepo.delete({
|
||||||
|
id: passkeyId,
|
||||||
|
user: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.affected === 0) {
|
||||||
|
throw new NotFoundException('未找到对应的通行证');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user