feat(api-auth): implement refresh token rotation and revoke
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/todolist?schema=public"
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/todolist?schema=public"
|
||||||
AUTH_ACCESS_SECRET="dev-access-secret"
|
AUTH_ACCESS_SECRET="dev-access-secret"
|
||||||
AUTH_ACCESS_EXPIRES_IN_SECONDS="900"
|
AUTH_ACCESS_EXPIRES_IN_SECONDS="900"
|
||||||
|
AUTH_REFRESH_EXPIRES_IN_SECONDS="2592000"
|
||||||
AUTH_EMAIL_CODE_TTL_SECONDS="300"
|
AUTH_EMAIL_CODE_TTL_SECONDS="300"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Body, Controller, Post } from "@nestjs/common";
|
import { Body, Controller, Post } from "@nestjs/common";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { EmailLoginDto } from "./dto/email-login.dto";
|
import { EmailLoginDto } from "./dto/email-login.dto";
|
||||||
|
import { RefreshTokenDto } from "./dto/refresh-token.dto";
|
||||||
import { SendEmailCodeDto } from "./dto/send-email-code.dto";
|
import { SendEmailCodeDto } from "./dto/send-email-code.dto";
|
||||||
|
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
@@ -15,14 +16,31 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("email/login")
|
@Post("email/login")
|
||||||
async loginWithEmailCode(
|
async loginWithEmailCode(@Body() body: EmailLoginDto): Promise<{
|
||||||
@Body() body: EmailLoginDto
|
|
||||||
): Promise<{
|
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
tokenType: "Bearer";
|
tokenType: "Bearer";
|
||||||
expiresInSeconds: number;
|
expiresInSeconds: number;
|
||||||
|
refreshToken: string;
|
||||||
|
refreshExpiresInSeconds: number;
|
||||||
user: { id: string; email: string };
|
user: { id: string; email: string };
|
||||||
}> {
|
}> {
|
||||||
return this.authService.loginWithEmailCode(body.email, body.code);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,27 @@ type AuthUser = {
|
|||||||
email: string;
|
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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly emailCodeStore = new Map<string, EmailCodeEntry>();
|
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(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@@ -39,15 +56,7 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginWithEmailCode(
|
async loginWithEmailCode(email: string, code: string): Promise<AuthTokenResult> {
|
||||||
email: string,
|
|
||||||
code: string
|
|
||||||
): Promise<{
|
|
||||||
accessToken: string;
|
|
||||||
tokenType: "Bearer";
|
|
||||||
expiresInSeconds: number;
|
|
||||||
user: AuthUser;
|
|
||||||
}> {
|
|
||||||
const lowerEmail = email.toLowerCase();
|
const lowerEmail = email.toLowerCase();
|
||||||
const codeEntry = this.emailCodeStore.get(lowerEmail);
|
const codeEntry = this.emailCodeStore.get(lowerEmail);
|
||||||
|
|
||||||
@@ -67,24 +76,43 @@ export class AuthService {
|
|||||||
this.emailCodeStore.delete(lowerEmail);
|
this.emailCodeStore.delete(lowerEmail);
|
||||||
|
|
||||||
const user = this.getOrCreateUser(lowerEmail);
|
const user = this.getOrCreateUser(lowerEmail);
|
||||||
const expiresInSeconds = Number(
|
return this.issueTokens(user);
|
||||||
this.configService.get("AUTH_ACCESS_EXPIRES_IN_SECONDS") ?? 900
|
}
|
||||||
);
|
|
||||||
const accessToken = await this.jwtService.signAsync({
|
|
||||||
sub: user.id,
|
|
||||||
email: user.email
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
async refreshTokens(refreshToken: string): Promise<AuthTokenResult> {
|
||||||
accessToken,
|
const entry = this.refreshTokenStore.get(refreshToken);
|
||||||
tokenType: "Bearer",
|
if (!entry) {
|
||||||
expiresInSeconds,
|
throw new UnauthorizedException("刷新令牌不存在");
|
||||||
user
|
}
|
||||||
};
|
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 {
|
private getOrCreateUser(email: string): AuthUser {
|
||||||
const existingUser = this.userStore.get(email);
|
const existingUser = this.userStoreByEmail.get(email);
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return existingUser;
|
return existingUser;
|
||||||
}
|
}
|
||||||
@@ -93,7 +121,8 @@ export class AuthService {
|
|||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
email
|
email
|
||||||
};
|
};
|
||||||
this.userStore.set(email, newUser);
|
this.userStoreByEmail.set(email, newUser);
|
||||||
|
this.userStoreById.set(newUser.id, newUser);
|
||||||
|
|
||||||
return newUser;
|
return newUser;
|
||||||
}
|
}
|
||||||
@@ -101,4 +130,32 @@ export class AuthService {
|
|||||||
private generateCode(): string {
|
private generateCode(): string {
|
||||||
return String(Math.floor(100000 + Math.random() * 900000));
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user