feat(api-auth): send email login codes via SMTP
This commit is contained in:
@@ -47,6 +47,7 @@
|
||||
"@prisma/client": "^7.6.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"nodemailer": "^8.0.4",
|
||||
"otplib": "^13.4.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-github2": "^0.1.12",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
ServiceUnavailableException
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { createTransport, type Transporter } from "nodemailer";
|
||||
|
||||
type MailRuntimeConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user: string;
|
||||
pass: string;
|
||||
fromName: string;
|
||||
fromAddress: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthMailService {
|
||||
private readonly logger = new Logger(AuthMailService.name);
|
||||
private cachedConfig: MailRuntimeConfig | null = null;
|
||||
private transporter: Transporter | null = null;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async sendLoginCode(email: string, code: string, ttlSeconds: number): Promise<void> {
|
||||
const config = this.getRuntimeConfig();
|
||||
const transporter = this.getTransporter(config);
|
||||
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: this.resolveFromField(config),
|
||||
to: email,
|
||||
subject: "TodoList 登录验证码",
|
||||
text: `你的验证码是 ${code},${ttlSeconds} 秒内有效。`,
|
||||
html: `<p>你的验证码是 <strong>${code}</strong>,${ttlSeconds} 秒内有效。</p>`
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`验证码邮件发送失败: ${email}`,
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
throw new ServiceUnavailableException("验证码邮件发送失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
private getTransporter(config: MailRuntimeConfig): Transporter {
|
||||
if (this.transporter) {
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
this.transporter = createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: {
|
||||
user: config.user,
|
||||
pass: config.pass
|
||||
}
|
||||
});
|
||||
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
private getRuntimeConfig(): MailRuntimeConfig {
|
||||
if (this.cachedConfig) {
|
||||
return this.cachedConfig;
|
||||
}
|
||||
|
||||
const host = this.getRequiredString("MAIL_SMTP_HOST");
|
||||
const port = this.getRequiredNumber("MAIL_SMTP_PORT");
|
||||
const secure = this.getBoolean("MAIL_SMTP_SECURE", port === 465);
|
||||
const user = this.getRequiredString("MAIL_SMTP_USER");
|
||||
const pass = this.getRequiredString("MAIL_SMTP_PASS");
|
||||
const fromName = this.configService.get<string>("MAIL_FROM_NAME")?.trim() || "TodoList";
|
||||
const fromAddress = this.configService.get<string>("MAIL_FROM_ADDRESS")?.trim() || user;
|
||||
|
||||
const config: MailRuntimeConfig = {
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
user,
|
||||
pass,
|
||||
fromName,
|
||||
fromAddress
|
||||
};
|
||||
|
||||
this.cachedConfig = config;
|
||||
return config;
|
||||
}
|
||||
|
||||
private getRequiredString(key: string): string {
|
||||
const value = this.configService.get<string>(key)?.trim();
|
||||
if (!value) {
|
||||
throw new InternalServerErrorException(`邮件配置缺失: ${key}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private getRequiredNumber(key: string): number {
|
||||
const rawValue = this.configService.get<string>(key)?.trim();
|
||||
if (!rawValue) {
|
||||
throw new InternalServerErrorException(`邮件配置缺失: ${key}`);
|
||||
}
|
||||
|
||||
const parsedValue = Number(rawValue);
|
||||
if (!Number.isFinite(parsedValue)) {
|
||||
throw new InternalServerErrorException(`邮件配置格式错误: ${key}`);
|
||||
}
|
||||
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
private getBoolean(key: string, fallback: boolean): boolean {
|
||||
const rawValue = this.configService.get<string>(key);
|
||||
if (!rawValue) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalizedValue = rawValue.trim().toLowerCase();
|
||||
return normalizedValue === "true" || normalizedValue === "1";
|
||||
}
|
||||
|
||||
private resolveFromField(config: MailRuntimeConfig): string {
|
||||
const sanitizedName = config.fromName.replace(/"/g, "");
|
||||
return `"${sanitizedName}" <${config.fromAddress}>`;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export class AuthController {
|
||||
@Post("email/send-code")
|
||||
async sendEmailCode(
|
||||
@Body() body: SendEmailCodeDto
|
||||
): Promise<{ success: boolean; expiresInSeconds: number; debugCode: string }> {
|
||||
): Promise<{ success: boolean; expiresInSeconds: number }> {
|
||||
return this.authService.sendEmailCode(body.email);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthMailService } from "./auth-mail.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { GithubStrategy } from "./strategies/github.strategy";
|
||||
import { QqStrategy } from "./strategies/qq.strategy";
|
||||
@@ -27,6 +28,6 @@ import { WechatStrategy } from "./strategies/wechat.strategy";
|
||||
})
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, GithubStrategy, QqStrategy, WechatStrategy]
|
||||
providers: [AuthService, AuthMailService, GithubStrategy, QqStrategy, WechatStrategy]
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { authenticator } from "@otplib/preset-default";
|
||||
import { AuthMailService } from "./auth-mail.service";
|
||||
|
||||
type EmailCodeEntry = {
|
||||
code: string;
|
||||
@@ -44,22 +45,22 @@ export class AuthService {
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly jwtService: JwtService
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly authMailService: AuthMailService
|
||||
) {}
|
||||
|
||||
async sendEmailCode(
|
||||
email: string
|
||||
): Promise<{ success: boolean; expiresInSeconds: number; debugCode: string }> {
|
||||
async sendEmailCode(email: string): Promise<{ success: boolean; expiresInSeconds: number }> {
|
||||
const ttlSeconds = Number(this.configService.get("AUTH_EMAIL_CODE_TTL_SECONDS") ?? 300);
|
||||
const code = this.generateCode();
|
||||
const expiresAt = Date.now() + ttlSeconds * 1000;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
this.emailCodeStore.set(email.toLowerCase(), { code, expiresAt });
|
||||
await this.authMailService.sendLoginCode(normalizedEmail, code, ttlSeconds);
|
||||
this.emailCodeStore.set(normalizedEmail, { code, expiresAt });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
expiresInSeconds: ttlSeconds,
|
||||
debugCode: code
|
||||
expiresInSeconds: ttlSeconds
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Generated
+12
@@ -70,6 +70,9 @@ importers:
|
||||
class-validator:
|
||||
specifier: ^0.15.1
|
||||
version: 0.15.1
|
||||
nodemailer:
|
||||
specifier: ^8.0.4
|
||||
version: 8.0.4
|
||||
otplib:
|
||||
specifier: ^13.4.0
|
||||
version: 13.4.0
|
||||
@@ -6091,6 +6094,13 @@ packages:
|
||||
integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==
|
||||
}
|
||||
|
||||
nodemailer@8.0.4:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==
|
||||
}
|
||||
engines: { node: ">=6.0.0" }
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -12248,6 +12258,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.37: {}
|
||||
|
||||
nodemailer@8.0.4: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
npm-run-path@4.0.1:
|
||||
|
||||
Reference in New Issue
Block a user