feat(api-auth): send email login codes via SMTP
This commit is contained in:
@@ -47,6 +47,7 @@
|
|||||||
"@prisma/client": "^7.6.0",
|
"@prisma/client": "^7.6.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
"otplib": "^13.4.0",
|
"otplib": "^13.4.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-github2": "^0.1.12",
|
"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")
|
@Post("email/send-code")
|
||||||
async sendEmailCode(
|
async sendEmailCode(
|
||||||
@Body() body: SendEmailCodeDto
|
@Body() body: SendEmailCodeDto
|
||||||
): Promise<{ success: boolean; expiresInSeconds: number; debugCode: string }> {
|
): Promise<{ success: boolean; expiresInSeconds: number }> {
|
||||||
return this.authService.sendEmailCode(body.email);
|
return this.authService.sendEmailCode(body.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from "@nestjs/config";
|
|||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
import { PassportModule } from "@nestjs/passport";
|
import { PassportModule } from "@nestjs/passport";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
|
import { AuthMailService } from "./auth-mail.service";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { GithubStrategy } from "./strategies/github.strategy";
|
import { GithubStrategy } from "./strategies/github.strategy";
|
||||||
import { QqStrategy } from "./strategies/qq.strategy";
|
import { QqStrategy } from "./strategies/qq.strategy";
|
||||||
@@ -27,6 +28,6 @@ import { WechatStrategy } from "./strategies/wechat.strategy";
|
|||||||
})
|
})
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, GithubStrategy, QqStrategy, WechatStrategy]
|
providers: [AuthService, AuthMailService, GithubStrategy, QqStrategy, WechatStrategy]
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { authenticator } from "@otplib/preset-default";
|
import { authenticator } from "@otplib/preset-default";
|
||||||
|
import { AuthMailService } from "./auth-mail.service";
|
||||||
|
|
||||||
type EmailCodeEntry = {
|
type EmailCodeEntry = {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -44,22 +45,22 @@ export class AuthService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly jwtService: JwtService
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly authMailService: AuthMailService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async sendEmailCode(
|
async sendEmailCode(email: string): Promise<{ success: boolean; expiresInSeconds: number }> {
|
||||||
email: string
|
|
||||||
): Promise<{ success: boolean; expiresInSeconds: number; debugCode: string }> {
|
|
||||||
const ttlSeconds = Number(this.configService.get("AUTH_EMAIL_CODE_TTL_SECONDS") ?? 300);
|
const ttlSeconds = Number(this.configService.get("AUTH_EMAIL_CODE_TTL_SECONDS") ?? 300);
|
||||||
const code = this.generateCode();
|
const code = this.generateCode();
|
||||||
const expiresAt = Date.now() + ttlSeconds * 1000;
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
expiresInSeconds: ttlSeconds,
|
expiresInSeconds: ttlSeconds
|
||||||
debugCode: code
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+12
@@ -70,6 +70,9 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.15.1
|
specifier: ^0.15.1
|
||||||
version: 0.15.1
|
version: 0.15.1
|
||||||
|
nodemailer:
|
||||||
|
specifier: ^8.0.4
|
||||||
|
version: 8.0.4
|
||||||
otplib:
|
otplib:
|
||||||
specifier: ^13.4.0
|
specifier: ^13.4.0
|
||||||
version: 13.4.0
|
version: 13.4.0
|
||||||
@@ -6091,6 +6094,13 @@ packages:
|
|||||||
integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==
|
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:
|
normalize-path@3.0.0:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -12248,6 +12258,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.37: {}
|
node-releases@2.0.37: {}
|
||||||
|
|
||||||
|
nodemailer@8.0.4: {}
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
||||||
npm-run-path@4.0.1:
|
npm-run-path@4.0.1:
|
||||||
|
|||||||
Reference in New Issue
Block a user