feat(api-auth): implement refresh token rotation and revoke

This commit is contained in:
2026-04-04 21:12:14 +08:00
parent efe55fdc2f
commit 074942fab4
4 changed files with 111 additions and 28 deletions
+21 -3
View File
@@ -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);
}
}
+82 -25
View File
@@ -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<string, EmailCodeEntry>();
private readonly userStore = new Map<string, AuthUser>();
private readonly userStoreByEmail = new Map<string, AuthUser>();
private readonly userStoreById = new Map<string, AuthUser>();
private readonly refreshTokenStore = new Map<string, RefreshTokenEntry>();
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<AuthTokenResult> {
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<AuthTokenResult> {
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<AuthTokenResult> {
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
};
}
}
@@ -0,0 +1,7 @@
import { IsString, MinLength } from "class-validator";
export class RefreshTokenDto {
@IsString()
@MinLength(20)
refreshToken!: string;
}