feat(api-auth): implement email code send and login

This commit is contained in:
2026-04-04 21:02:17 +08:00
parent 5d650e00f6
commit efe55fdc2f
13 changed files with 2282 additions and 13 deletions
+4 -1
View File
@@ -1 +1,4 @@
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_EXPIRES_IN_SECONDS="900"
AUTH_EMAIL_CODE_TTL_SECONDS="300"
+3
View File
@@ -3,3 +3,6 @@ node_modules
.env
/generated/prisma
dist
prisma.config.js
prisma.config.js.map
+20 -3
View File
@@ -5,16 +5,33 @@
"scripts": {
"prisma:generate": "prisma generate",
"prisma:format": "prisma format",
"prisma:validate": "prisma validate"
"prisma:validate": "prisma validate",
"start": "node dist/main.js",
"start:dev": "ts-node-dev --respawn --transpile-only src/main.ts",
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "echo api tests pending"
},
"license": "GPL-3.0-or-later",
"devDependencies": {
"@types/node": "^25.5.2",
"dotenv": "^16.6.1",
"prisma": "^7.6.0"
"prisma": "^7.6.0",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.9.3"
},
"private": true,
"dependencies": {
"@prisma/client": "^7.6.0"
"@nestjs/common": "^11.1.18",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.18",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.1.18",
"@prisma/client": "^7.6.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { AuthModule } from "./auth/auth.module";
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ".env"
}),
AuthModule
]
})
export class AppModule {}
+28
View File
@@ -0,0 +1,28 @@
import { Body, Controller, Post } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { EmailLoginDto } from "./dto/email-login.dto";
import { SendEmailCodeDto } from "./dto/send-email-code.dto";
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post("email/send-code")
async sendEmailCode(
@Body() body: SendEmailCodeDto
): Promise<{ success: boolean; expiresInSeconds: number; debugCode: string }> {
return this.authService.sendEmailCode(body.email);
}
@Post("email/login")
async loginWithEmailCode(
@Body() body: EmailLoginDto
): Promise<{
accessToken: string;
tokenType: "Bearer";
expiresInSeconds: number;
user: { id: string; email: string };
}> {
return this.authService.loginWithEmailCode(body.email, body.code);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
@Module({
imports: [
ConfigModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const expiresInSeconds = Number(configService.get("AUTH_ACCESS_EXPIRES_IN_SECONDS") ?? 900);
return {
secret: configService.get<string>("AUTH_ACCESS_SECRET") ?? "dev-access-secret",
signOptions: {
expiresIn: expiresInSeconds
}
};
}
})
],
controllers: [AuthController],
providers: [AuthService]
})
export class AuthModule {}
+104
View File
@@ -0,0 +1,104 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { randomUUID } from "node:crypto";
type EmailCodeEntry = {
code: string;
expiresAt: number;
};
type AuthUser = {
id: string;
email: string;
};
@Injectable()
export class AuthService {
private readonly emailCodeStore = new Map<string, EmailCodeEntry>();
private readonly userStore = new Map<string, AuthUser>();
constructor(
private readonly configService: ConfigService,
private readonly jwtService: JwtService
) {}
async sendEmailCode(
email: string
): Promise<{ success: boolean; expiresInSeconds: number; debugCode: string }> {
const ttlSeconds = Number(this.configService.get("AUTH_EMAIL_CODE_TTL_SECONDS") ?? 300);
const code = this.generateCode();
const expiresAt = Date.now() + ttlSeconds * 1000;
this.emailCodeStore.set(email.toLowerCase(), { code, expiresAt });
return {
success: true,
expiresInSeconds: ttlSeconds,
debugCode: code
};
}
async loginWithEmailCode(
email: string,
code: string
): Promise<{
accessToken: string;
tokenType: "Bearer";
expiresInSeconds: number;
user: AuthUser;
}> {
const lowerEmail = email.toLowerCase();
const codeEntry = this.emailCodeStore.get(lowerEmail);
if (!codeEntry) {
throw new UnauthorizedException("验证码不存在或已失效");
}
if (codeEntry.expiresAt < Date.now()) {
this.emailCodeStore.delete(lowerEmail);
throw new UnauthorizedException("验证码已过期");
}
if (codeEntry.code !== code) {
throw new UnauthorizedException("验证码错误");
}
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 {
accessToken,
tokenType: "Bearer",
expiresInSeconds,
user
};
}
private getOrCreateUser(email: string): AuthUser {
const existingUser = this.userStore.get(email);
if (existingUser) {
return existingUser;
}
const newUser = {
id: randomUUID(),
email
};
this.userStore.set(email, newUser);
return newUser;
}
private generateCode(): string {
return String(Math.floor(100000 + Math.random() * 900000));
}
}
+11
View File
@@ -0,0 +1,11 @@
import { IsEmail, IsString, Length, Matches } from "class-validator";
export class EmailLoginDto {
@IsEmail()
email!: string;
@IsString()
@Length(6, 6)
@Matches(/^\d{6}$/)
code!: string;
}
@@ -0,0 +1,6 @@
import { IsEmail } from "class-validator";
export class SendEmailCodeDto {
@IsEmail()
email!: string;
}
+19
View File
@@ -0,0 +1,19 @@
import "reflect-metadata";
import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true
})
);
await app.listen(3000);
}
void bootstrap();
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../packages/tsconfig/nest-app.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}
+2031 -9
View File
File diff suppressed because it is too large Load Diff