feat(api-auth): add totp 2fa enrollment and verify

This commit is contained in:
2026-04-04 21:21:43 +08:00
parent 485fe43140
commit 62b0514da7
7 changed files with 384 additions and 116 deletions
+19
View File
@@ -4,6 +4,8 @@ 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";
import { TwoFactorEnrollDto } from "./dto/two-factor-enroll.dto";
import { TwoFactorVerifyDto } from "./dto/two-factor-verify.dto";
@Controller("auth")
export class AuthController {
@@ -45,6 +47,23 @@ export class AuthController {
return this.authService.revokeRefreshToken(body.refreshToken);
}
@Post("2fa/enroll")
async enrollTwoFactor(@Body() body: TwoFactorEnrollDto): Promise<{
userId: string;
secret: string;
otpauthUrl: string;
enabled: boolean;
}> {
return this.authService.enrollTwoFactor(body.email);
}
@Post("2fa/verify")
async verifyTwoFactor(
@Body() body: TwoFactorVerifyDto
): Promise<{ success: boolean; enabled: boolean }> {
return this.authService.verifyTwoFactor(body.email, body.token);
}
@Get("oauth/github")
@UseGuards(AuthGuard("github"))
githubLogin(): void {}
+50
View File
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { randomUUID } from "node:crypto";
import { authenticator } from "@otplib/preset-default";
type EmailCodeEntry = {
code: string;
@@ -19,6 +20,11 @@ type RefreshTokenEntry = {
revokedAt?: number;
};
type TwoFactorEntry = {
secret: string;
enabled: boolean;
};
type AuthTokenResult = {
accessToken: string;
tokenType: "Bearer";
@@ -34,6 +40,7 @@ export class AuthService {
private readonly userStoreByEmail = new Map<string, AuthUser>();
private readonly userStoreById = new Map<string, AuthUser>();
private readonly refreshTokenStore = new Map<string, RefreshTokenEntry>();
private readonly twoFactorStore = new Map<string, TwoFactorEntry>();
constructor(
private readonly configService: ConfigService,
@@ -111,6 +118,49 @@ export class AuthService {
return { success: true };
}
async enrollTwoFactor(
email: string
): Promise<{ userId: string; secret: string; otpauthUrl: string; enabled: boolean }> {
const user = this.getOrCreateUser(email.toLowerCase());
const secret = authenticator.generateSecret();
const issuer = this.configService.get<string>("AUTH_TOTP_ISSUER") ?? "TodoList";
const otpauthUrl = authenticator.keyuri(user.email, issuer, secret);
this.twoFactorStore.set(user.id, {
secret,
enabled: false
});
return {
userId: user.id,
secret,
otpauthUrl,
enabled: false
};
}
async verifyTwoFactor(
email: string,
token: string
): Promise<{ success: boolean; enabled: boolean }> {
const user = this.getOrCreateUser(email.toLowerCase());
const entry = this.twoFactorStore.get(user.id);
if (!entry) {
throw new UnauthorizedException("尚未启用两步验证");
}
const valid = authenticator.check(token, entry.secret);
if (!valid) {
throw new UnauthorizedException("两步验证码错误");
}
entry.enabled = true;
return {
success: true,
enabled: true
};
}
private getOrCreateUser(email: string): AuthUser {
const existingUser = this.userStoreByEmail.get(email);
if (existingUser) {
@@ -0,0 +1,6 @@
import { IsEmail } from "class-validator";
export class TwoFactorEnrollDto {
@IsEmail()
email!: string;
}
@@ -0,0 +1,11 @@
import { IsEmail, IsString, Length, Matches } from "class-validator";
export class TwoFactorVerifyDto {
@IsEmail()
email!: string;
@IsString()
@Length(6, 6)
@Matches(/^\d{6}$/)
token!: string;
}