From 074942fab40dadb97da26e2217d4839741f2f7dc Mon Sep 17 00:00:00 2001 From: Yaosanqi137 Date: Sat, 4 Apr 2026 21:12:14 +0800 Subject: [PATCH] feat(api-auth): implement refresh token rotation and revoke --- apps/api/.env.example | 1 + apps/api/src/auth/auth.controller.ts | 24 ++++- apps/api/src/auth/auth.service.ts | 107 ++++++++++++++++----- apps/api/src/auth/dto/refresh-token.dto.ts | 7 ++ 4 files changed, 111 insertions(+), 28 deletions(-) create mode 100644 apps/api/src/auth/dto/refresh-token.dto.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 6406c52..41b5593 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,4 +1,5 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/todolist?schema=public" AUTH_ACCESS_SECRET="dev-access-secret" AUTH_ACCESS_EXPIRES_IN_SECONDS="900" +AUTH_REFRESH_EXPIRES_IN_SECONDS="2592000" AUTH_EMAIL_CODE_TTL_SECONDS="300" diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 6708a53..55d2329 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Post } from "@nestjs/common"; import { AuthService } from "./auth.service"; import { EmailLoginDto } from "./dto/email-login.dto"; +import { RefreshTokenDto } from "./dto/refresh-token.dto"; import { SendEmailCodeDto } from "./dto/send-email-code.dto"; @Controller("auth") @@ -15,14 +16,31 @@ export class AuthController { } @Post("email/login") - async loginWithEmailCode( - @Body() body: EmailLoginDto - ): Promise<{ + async loginWithEmailCode(@Body() body: EmailLoginDto): Promise<{ accessToken: string; tokenType: "Bearer"; expiresInSeconds: number; + refreshToken: string; + refreshExpiresInSeconds: number; user: { id: string; email: string }; }> { return this.authService.loginWithEmailCode(body.email, body.code); } + + @Post("token/refresh") + async refreshTokens(@Body() body: RefreshTokenDto): Promise<{ + accessToken: string; + tokenType: "Bearer"; + expiresInSeconds: number; + refreshToken: string; + refreshExpiresInSeconds: number; + user: { id: string; email: string }; + }> { + return this.authService.refreshTokens(body.refreshToken); + } + + @Post("token/revoke") + async revokeRefreshToken(@Body() body: RefreshTokenDto): Promise<{ success: boolean }> { + return this.authService.revokeRefreshToken(body.refreshToken); + } } diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 9ede99c..d48f917 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -13,10 +13,27 @@ type AuthUser = { email: string; }; +type RefreshTokenEntry = { + userId: string; + expiresAt: number; + revokedAt?: number; +}; + +type AuthTokenResult = { + accessToken: string; + tokenType: "Bearer"; + expiresInSeconds: number; + refreshToken: string; + refreshExpiresInSeconds: number; + user: AuthUser; +}; + @Injectable() export class AuthService { private readonly emailCodeStore = new Map(); - private readonly userStore = new Map(); + private readonly userStoreByEmail = new Map(); + private readonly userStoreById = new Map(); + private readonly refreshTokenStore = new Map(); constructor( private readonly configService: ConfigService, @@ -39,15 +56,7 @@ export class AuthService { }; } - async loginWithEmailCode( - email: string, - code: string - ): Promise<{ - accessToken: string; - tokenType: "Bearer"; - expiresInSeconds: number; - user: AuthUser; - }> { + async loginWithEmailCode(email: string, code: string): Promise { const lowerEmail = email.toLowerCase(); const codeEntry = this.emailCodeStore.get(lowerEmail); @@ -67,24 +76,43 @@ export class AuthService { this.emailCodeStore.delete(lowerEmail); const user = this.getOrCreateUser(lowerEmail); - const expiresInSeconds = Number( - this.configService.get("AUTH_ACCESS_EXPIRES_IN_SECONDS") ?? 900 - ); - const accessToken = await this.jwtService.signAsync({ - sub: user.id, - email: user.email - }); + return this.issueTokens(user); + } - return { - accessToken, - tokenType: "Bearer", - expiresInSeconds, - user - }; + async refreshTokens(refreshToken: string): Promise { + const entry = this.refreshTokenStore.get(refreshToken); + if (!entry) { + throw new UnauthorizedException("刷新令牌不存在"); + } + if (entry.revokedAt) { + throw new UnauthorizedException("刷新令牌已注销"); + } + if (entry.expiresAt < Date.now()) { + this.refreshTokenStore.delete(refreshToken); + throw new UnauthorizedException("刷新令牌已过期"); + } + + const user = this.userStoreById.get(entry.userId); + if (!user) { + throw new UnauthorizedException("用户不存在"); + } + + entry.revokedAt = Date.now(); + return this.issueTokens(user); + } + + async revokeRefreshToken(refreshToken: string): Promise<{ success: boolean }> { + const entry = this.refreshTokenStore.get(refreshToken); + if (!entry) { + return { success: true }; + } + + entry.revokedAt = Date.now(); + return { success: true }; } private getOrCreateUser(email: string): AuthUser { - const existingUser = this.userStore.get(email); + const existingUser = this.userStoreByEmail.get(email); if (existingUser) { return existingUser; } @@ -93,7 +121,8 @@ export class AuthService { id: randomUUID(), email }; - this.userStore.set(email, newUser); + this.userStoreByEmail.set(email, newUser); + this.userStoreById.set(newUser.id, newUser); return newUser; } @@ -101,4 +130,32 @@ export class AuthService { private generateCode(): string { return String(Math.floor(100000 + Math.random() * 900000)); } + + private async issueTokens(user: AuthUser): Promise { + const accessExpiresInSeconds = Number( + this.configService.get("AUTH_ACCESS_EXPIRES_IN_SECONDS") ?? 900 + ); + const refreshExpiresInSeconds = Number( + this.configService.get("AUTH_REFRESH_EXPIRES_IN_SECONDS") ?? 2592000 + ); + const accessToken = await this.jwtService.signAsync({ + sub: user.id, + email: user.email + }); + const refreshToken = `${randomUUID()}${randomUUID()}`; + + this.refreshTokenStore.set(refreshToken, { + userId: user.id, + expiresAt: Date.now() + refreshExpiresInSeconds * 1000 + }); + + return { + accessToken, + tokenType: "Bearer", + expiresInSeconds: accessExpiresInSeconds, + refreshToken, + refreshExpiresInSeconds, + user + }; + } } diff --git a/apps/api/src/auth/dto/refresh-token.dto.ts b/apps/api/src/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..2d91b3c --- /dev/null +++ b/apps/api/src/auth/dto/refresh-token.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from "class-validator"; + +export class RefreshTokenDto { + @IsString() + @MinLength(20) + refreshToken!: string; +}