feat(auth): 完整实现 SMS 手机注册/登录/验证系统

参考 rwadurian 项目的成熟实现,在 Genex auth-service 上全面增强短信验证体系。

## 新增功能

### Domain 层
- Phone Value Object: E.164 标准化、中国大陆格式自动补+86、掩码显示(138****8000)
- SmsCode Value Object: crypto 安全随机6位生成、格式验证
- SmsVerification Entity: 验证码记录持久化,支持4种类型(REGISTER/LOGIN/RESET_PASSWORD/CHANGE_PHONE)
- SmsLog Entity: SMS发送日志审计追踪(provider/status/error)
- User Entity 增强: loginFailCount + lockedUntil 字段,指数退避锁定策略(1→2→4→8...→1440分钟)
- 5个新 Domain Events: SmsCodeSent, SmsCodeVerified, AccountLocked, PhoneChanged, PasswordReset

### Infrastructure 层
- 3个 SQL 迁移: users表锁定字段(041), sms_verifications表(042), sms_logs表(043)
- SmsVerification/SmsLog TypeORM Repository 实现
- SMS Provider 抽象层: ISmsProvider 接口 + ConsoleSmsProvider(开发) + AliyunSmsProvider(生产)
- Redis SmsCodeService 增强: 类型前缀 auth:sms:{type}:{phone},保留向后兼容

### Application 层
- 独立 SmsService: 发送验证码(日限额10条+业务规则校验) + 验证验证码(尝试限制5次)
- AuthService 重构: 注册需SMS验证、密码登录带锁定检查、+resetPassword/changePhone

### Interface 层
- 新端点: POST /auth/sms/send, POST /auth/reset-password, POST /auth/change-phone
- DTO 更新: RegisterDto 增加 smsCode 必填, SendSmsCodeDto 增加 type 枚举
- 全部端点 Swagger 文档

### 配置
- .env.example: SMS_ENABLED, ALIYUN_SMS_*, SMS_DAILY_LIMIT, LOGIN_MAX_FAIL_ATTEMPTS
- auth.module: SMS_PROVIDER 按 SMS_ENABLED 环境变量自动切换

## API 端点一览
- POST /api/v1/auth/sms/send — 发送验证码(4种类型)
- POST /api/v1/auth/register — 手机注册(phone+smsCode+password)
- POST /api/v1/auth/login — 密码登录(带锁定检查)
- POST /api/v1/auth/login-phone — 短信验证码登录
- POST /api/v1/auth/reset-password — 重置密码
- POST /api/v1/auth/change-phone — 换绑手机(需登录)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-23 19:12:57 -08:00
parent 2ff8b48e50
commit e89ec82406
28 changed files with 1324 additions and 231 deletions

View File

@ -50,6 +50,24 @@ MINIO_ACCESS_KEY=genex-admin
MINIO_SECRET_KEY=genex-minio-secret
MINIO_USE_SSL=false
# --- SMS Service ---
SMS_ENABLED=false
SMS_CODE_EXPIRE_SECONDS=300
SMS_CODE_LENGTH=6
SMS_DAILY_LIMIT=10
SMS_MAX_VERIFY_ATTEMPTS=5
# --- Aliyun SMS (when SMS_ENABLED=true) ---
ALIYUN_ACCESS_KEY_ID=
ALIYUN_ACCESS_KEY_SECRET=
ALIYUN_SMS_SIGN_NAME=券金融
ALIYUN_SMS_TEMPLATE_CODE=
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
# --- Account Lockout ---
LOGIN_MAX_FAIL_ATTEMPTS=6
LOGIN_MAX_LOCK_MINUTES=1440
# --- External Services (all mocked in MVP) ---
CHAIN_RPC_URL=http://localhost:26657
SENDGRID_API_KEY=mock-key

View File

@ -0,0 +1,4 @@
-- 041: Add login lockout fields to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS login_fail_count INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ;

View File

@ -0,0 +1,14 @@
-- 042: SMS verification codes table
CREATE TABLE IF NOT EXISTS sms_verifications (
id BIGSERIAL PRIMARY KEY,
phone VARCHAR(20) NOT NULL,
code VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('REGISTER', 'LOGIN', 'RESET_PASSWORD', 'CHANGE_PHONE')),
expires_at TIMESTAMPTZ NOT NULL,
verified_at TIMESTAMPTZ,
attempts INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sms_verif_phone_type ON sms_verifications(phone, type);
CREATE INDEX idx_sms_verif_expires ON sms_verifications(expires_at);

View File

@ -0,0 +1,15 @@
-- 043: SMS delivery logs table (audit trail)
CREATE TABLE IF NOT EXISTS sms_logs (
id BIGSERIAL PRIMARY KEY,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
phone VARCHAR(20) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('REGISTER', 'LOGIN', 'RESET_PASSWORD', 'CHANGE_PHONE')),
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'SENT', 'DELIVERED', 'FAILED')),
provider VARCHAR(50),
provider_id VARCHAR(100),
error_msg TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sms_logs_phone ON sms_logs(phone);
CREATE INDEX idx_sms_logs_created ON sms_logs(created_at);

View File

@ -1,16 +1,25 @@
import { Injectable, Logger, UnauthorizedException, ConflictException, ForbiddenException, BadRequestException } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import {
Injectable,
Logger,
UnauthorizedException,
ConflictException,
ForbiddenException,
BadRequestException,
Inject,
} from '@nestjs/common';
import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface';
import { REFRESH_TOKEN_REPOSITORY, IRefreshTokenRepository } from '../../domain/repositories/refresh-token.repository.interface';
import { TokenService } from './token.service';
import { SmsService } from './sms.service';
import { Password } from '../../domain/value-objects/password.vo';
import { Phone } from '../../domain/value-objects/phone.vo';
import { UserRole, UserStatus } from '../../domain/entities/user.entity';
import { SmsVerificationType } from '../../domain/entities/sms-verification.entity';
import { EventPublisherService } from './event-publisher.service';
import { SmsCodeService } from '../../infrastructure/redis/sms-code.service';
export interface RegisterDto {
phone?: string;
email?: string;
phone: string;
smsCode: string;
password: string;
nickname?: string;
}
@ -28,13 +37,16 @@ export interface AuthTokens {
expiresIn: number;
}
export interface RegisterResult {
export interface AuthResult {
user: {
id: string;
phone: string | null;
email: string | null;
nickname: string | null;
avatarUrl: string | null;
role: string;
kycLevel: number;
walletMode: string;
};
tokens: AuthTokens;
}
@ -47,33 +59,31 @@ export class AuthService {
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
@Inject(REFRESH_TOKEN_REPOSITORY) private readonly refreshTokenRepo: IRefreshTokenRepository,
private readonly tokenService: TokenService,
private readonly smsService: SmsService,
private readonly eventPublisher: EventPublisherService,
private readonly smsCodeService: SmsCodeService,
) {}
async register(dto: RegisterDto): Promise<RegisterResult> {
// Validate at least one identifier
if (!dto.phone && !dto.email) {
throw new ConflictException('Phone or email is required');
/* ── Register ── */
async register(dto: RegisterDto): Promise<AuthResult> {
const phone = Phone.create(dto.phone);
// Check duplicate
const existing = await this.userRepo.findByPhone(phone.value);
if (existing) {
throw new ConflictException('该手机号已注册');
}
// Check duplicates
if (dto.phone) {
const existing = await this.userRepo.findByPhone(dto.phone);
if (existing) throw new ConflictException('Phone number already registered');
}
if (dto.email) {
const existing = await this.userRepo.findByEmail(dto.email);
if (existing) throw new ConflictException('Email already registered');
}
// Verify SMS code
await this.smsService.verifyCode(dto.phone, dto.smsCode, SmsVerificationType.REGISTER);
// Hash password
const password = await Password.create(dto.password);
// Create user
const user = await this.userRepo.create({
phone: dto.phone || null,
email: dto.email || null,
phone: phone.value,
email: null,
passwordHash: password.value,
nickname: dto.nickname || null,
role: UserRole.USER,
@ -84,8 +94,6 @@ export class AuthService {
// Generate tokens
const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel);
// Store refresh token
await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken);
// Publish event
@ -97,52 +105,51 @@ export class AuthService {
timestamp: new Date().toISOString(),
});
this.logger.log(`User registered: ${user.id}`);
return {
user: {
id: user.id,
phone: user.phone,
email: user.email,
role: user.role,
kycLevel: user.kycLevel,
},
tokens,
};
this.logger.log(`User registered: ${user.id} phone=${phone.masked}`);
return { user: this.toUserDto(user), tokens };
}
async login(dto: LoginDto): Promise<{ user: any; tokens: AuthTokens }> {
// Find user by phone or email
/* ── Password Login ── */
async login(dto: LoginDto): Promise<AuthResult> {
const user = await this.userRepo.findByPhoneOrEmail(dto.identifier);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
throw new UnauthorizedException('账号或密码错误');
}
// Check status
if (user.status === UserStatus.FROZEN) {
throw new ForbiddenException('Account is frozen');
}
if (user.status === UserStatus.DELETED) {
throw new UnauthorizedException('Account not found');
}
this.checkUserStatus(user);
this.checkAccountLock(user);
// Verify password
const password = Password.fromHash(user.passwordHash);
const valid = await password.verify(dto.password);
if (!valid) {
throw new UnauthorizedException('Invalid credentials');
const lockInfo = user.recordLoginFailure();
await this.userRepo.save(user);
if (lockInfo.lockMinutes) {
await this.eventPublisher.publishAccountLocked({
userId: user.id,
lockedUntil: user.lockedUntil!.toISOString(),
failCount: user.loginFailCount,
timestamp: new Date().toISOString(),
});
throw new ForbiddenException(
`登录失败次数过多,账号已锁定 ${lockInfo.lockMinutes} 分钟`,
);
}
throw new UnauthorizedException(
`账号或密码错误,还剩 ${lockInfo.remainingAttempts} 次尝试机会`,
);
}
// Update last login
await this.userRepo.updateLastLogin(user.id);
// Success
user.recordLoginSuccess(dto.ipAddress);
await this.userRepo.save(user);
// Generate tokens
const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel);
// Store refresh token
await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, dto.deviceInfo, dto.ipAddress);
// Publish event
await this.eventPublisher.publishUserLoggedIn({
userId: user.id,
ipAddress: dto.ipAddress || null,
@ -150,47 +157,143 @@ export class AuthService {
timestamp: new Date().toISOString(),
});
return {
user: {
id: user.id,
phone: user.phone,
email: user.email,
nickname: user.nickname,
avatarUrl: user.avatarUrl,
role: user.role,
kycLevel: user.kycLevel,
walletMode: user.walletMode,
},
tokens,
};
return { user: this.toUserDto(user), tokens };
}
/* ── SMS Login ── */
async loginWithPhone(
rawPhone: string,
smsCode: string,
deviceInfo?: string,
ipAddress?: string,
): Promise<AuthResult> {
const phone = Phone.create(rawPhone);
// Verify SMS code
await this.smsService.verifyCode(rawPhone, smsCode, SmsVerificationType.LOGIN);
const user = await this.userRepo.findByPhone(phone.value);
if (!user) {
throw new UnauthorizedException('该手机号未注册');
}
this.checkUserStatus(user);
// SMS login bypasses lockout (code-verified)
user.recordLoginSuccess(ipAddress);
await this.userRepo.save(user);
const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel);
await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, deviceInfo, ipAddress);
await this.eventPublisher.publishUserLoggedIn({
userId: user.id,
ipAddress: ipAddress || null,
deviceInfo: deviceInfo || null,
timestamp: new Date().toISOString(),
});
return { user: this.toUserDto(user), tokens };
}
/* ── Reset Password ── */
async resetPassword(
rawPhone: string,
smsCode: string,
newPassword: string,
): Promise<void> {
const phone = Phone.create(rawPhone);
// Verify SMS code
await this.smsService.verifyCode(rawPhone, smsCode, SmsVerificationType.RESET_PASSWORD);
const user = await this.userRepo.findByPhone(phone.value);
if (!user) {
throw new BadRequestException('该手机号未注册');
}
// Set new password
const passwordVo = await Password.create(newPassword);
user.passwordHash = passwordVo.value;
// Clear lockout
user.loginFailCount = 0;
user.lockedUntil = null;
await this.userRepo.save(user);
// Revoke all refresh tokens (force re-login)
await this.refreshTokenRepo.revokeByUserId(user.id);
await this.eventPublisher.publishPasswordReset({
userId: user.id,
phone: phone.value,
timestamp: new Date().toISOString(),
});
this.logger.log(`Password reset: userId=${user.id} phone=${phone.masked}`);
}
/* ── Change Phone ── */
async changePhone(
userId: string,
newRawPhone: string,
newSmsCode: string,
): Promise<void> {
const newPhone = Phone.create(newRawPhone);
// Verify new phone SMS code
await this.smsService.verifyCode(newRawPhone, newSmsCode, SmsVerificationType.CHANGE_PHONE);
const user = await this.userRepo.findById(userId);
if (!user) {
throw new UnauthorizedException('用户不存在');
}
// Check new phone not already used
const existing = await this.userRepo.findByPhone(newPhone.value);
if (existing) {
throw new ConflictException('该手机号已被其他账户使用');
}
const oldPhone = user.phone;
user.phone = newPhone.value;
await this.userRepo.save(user);
await this.eventPublisher.publishPhoneChanged({
userId: user.id,
oldPhone: oldPhone || '',
newPhone: newPhone.value,
timestamp: new Date().toISOString(),
});
this.logger.log(`Phone changed: userId=${user.id} new=${newPhone.masked}`);
}
/* ── Token Refresh ── */
async refreshToken(refreshToken: string): Promise<AuthTokens> {
const payload = await this.tokenService.verifyRefreshToken(refreshToken);
// Fetch user to get current role/kycLevel
const user = await this.userRepo.findById(payload.sub);
if (!user || user.status !== UserStatus.ACTIVE) {
throw new UnauthorizedException('User not found or inactive');
throw new UnauthorizedException('用户不存在或已停用');
}
// Revoke old refresh token
await this.tokenService.revokeRefreshToken(refreshToken);
// Generate new token pair
const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel);
// Store new refresh token
await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken);
return tokens;
}
/* ── Logout ── */
async logout(userId: string): Promise<void> {
// Revoke all refresh tokens for this user
await this.refreshTokenRepo.revokeByUserId(userId);
// Publish event
await this.eventPublisher.publishUserLoggedOut({
userId,
timestamp: new Date().toISOString(),
@ -199,115 +302,64 @@ export class AuthService {
this.logger.log(`User logged out: ${userId}`);
}
/* ── Change Password ── */
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
const user = await this.userRepo.findById(userId);
if (!user) throw new UnauthorizedException('User not found');
if (!user) throw new UnauthorizedException('用户不存在');
const currentPassword = Password.fromHash(user.passwordHash);
const valid = await currentPassword.verify(oldPassword);
if (!valid) throw new UnauthorizedException('Current password is incorrect');
if (!valid) throw new UnauthorizedException('当前密码错误');
const newHash = await Password.create(newPassword);
user.passwordHash = newHash.value;
await this.userRepo.save(user);
// Revoke all refresh tokens (force re-login)
await this.refreshTokenRepo.revokeByUserId(userId);
// Publish event
await this.eventPublisher.publishPasswordChanged({
userId,
timestamp: new Date().toISOString(),
});
}
/**
* Send a 6-digit SMS verification code to the given phone number.
* In dev mode, the code is logged to console.
*/
async sendSmsCode(phone: string): Promise<void> {
if (!phone) {
throw new BadRequestException('Phone number is required');
}
await this.smsCodeService.generateCode(phone);
this.logger.log(`SMS code sent to ${phone}`);
/* ── Send SMS Code (delegates to SmsService) ── */
async sendSmsCode(rawPhone: string, type: SmsVerificationType): Promise<{ expiresIn: number }> {
return this.smsService.sendCode(rawPhone, type);
}
/**
* Login with phone number and SMS verification code.
* If the user does not exist, a new account is created automatically.
*/
async loginWithPhone(phone: string, smsCode: string, ipAddress?: string): Promise<{ user: any; tokens: AuthTokens }> {
// Verify the SMS code
const valid = await this.smsCodeService.verifyCode(phone, smsCode);
if (!valid) {
throw new UnauthorizedException('Invalid or expired SMS code');
}
/* ── Private Helpers ── */
// Find or create user by phone
let user = await this.userRepo.findByPhone(phone);
if (!user) {
// Auto-register: create a new user with a random password hash
const randomPassword = await Password.create(`auto-${Date.now()}-${Math.random()}`);
user = await this.userRepo.create({
phone,
email: null,
passwordHash: randomPassword.value,
nickname: null,
role: UserRole.USER,
status: UserStatus.ACTIVE,
kycLevel: 0,
walletMode: 'standard',
});
await this.eventPublisher.publishUserRegistered({
userId: user.id,
phone: user.phone,
email: user.email,
role: user.role,
timestamp: new Date().toISOString(),
});
this.logger.log(`New user auto-registered via phone login: ${user.id}`);
}
// Check status
private checkUserStatus(user: any): void {
if (user.status === UserStatus.FROZEN) {
throw new ForbiddenException('Account is frozen');
throw new ForbiddenException('账号已被冻结');
}
if (user.status === UserStatus.DELETED) {
throw new UnauthorizedException('Account not found');
throw new UnauthorizedException('账号不存在');
}
}
// Update last login
await this.userRepo.updateLastLogin(user.id);
// Generate tokens
const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel);
// Store refresh token
await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, undefined, ipAddress);
// Publish login event
await this.eventPublisher.publishUserLoggedIn({
userId: user.id,
ipAddress: ipAddress || null,
deviceInfo: null,
timestamp: new Date().toISOString(),
});
private checkAccountLock(user: any): void {
if (user.isLocked) {
const mins = Math.ceil(user.lockRemainingSeconds / 60);
throw new ForbiddenException(
`账号已锁定,请 ${mins} 分钟后再试`,
);
}
}
private toUserDto(user: any) {
return {
user: {
id: user.id,
phone: user.phone,
email: user.email,
nickname: user.nickname,
avatarUrl: user.avatarUrl,
role: user.role,
kycLevel: user.kycLevel,
walletMode: user.walletMode,
},
tokens,
id: user.id,
phone: user.phone,
email: user.email,
nickname: user.nickname,
avatarUrl: user.avatarUrl,
role: user.role,
kycLevel: user.kycLevel,
walletMode: user.walletMode,
};
}
}

View File

@ -4,6 +4,9 @@ import {
UserLoggedInEvent,
UserLoggedOutEvent,
PasswordChangedEvent,
AccountLockedEvent,
PhoneChangedEvent,
PasswordResetEvent,
} from '../../domain/events/auth.events';
/**
@ -32,6 +35,18 @@ export class EventPublisherService {
await this.publishToOutbox('genex.user.password-changed', 'User', event.userId, 'user.password_changed', event);
}
async publishAccountLocked(event: AccountLockedEvent): Promise<void> {
await this.publishToOutbox('genex.user.locked', 'User', event.userId, 'user.account_locked', event);
}
async publishPhoneChanged(event: PhoneChangedEvent): Promise<void> {
await this.publishToOutbox('genex.user.phone-changed', 'User', event.userId, 'user.phone_changed', event);
}
async publishPasswordReset(event: PasswordResetEvent): Promise<void> {
await this.publishToOutbox('genex.user.password-reset', 'User', event.userId, 'user.password_reset', event);
}
private async publishToOutbox(
topic: string,
aggregateType: string,

View File

@ -0,0 +1,171 @@
import {
Injectable,
Logger,
BadRequestException,
Inject,
} from '@nestjs/common';
import { SmsVerificationType } from '../../domain/entities/sms-verification.entity';
import { SmsDeliveryStatus } from '../../domain/entities/sms-log.entity';
import {
ISmsVerificationRepository,
SMS_VERIFICATION_REPOSITORY,
} from '../../domain/repositories/sms-verification.repository.interface';
import {
ISmsLogRepository,
SMS_LOG_REPOSITORY,
} from '../../domain/repositories/sms-log.repository.interface';
import { IUserRepository, USER_REPOSITORY } from '../../domain/repositories/user.repository.interface';
import { ISmsProvider, SMS_PROVIDER } from '../../infrastructure/sms/sms-provider.interface';
import { SmsCodeService } from '../../infrastructure/redis/sms-code.service';
import { SmsCode } from '../../domain/value-objects/sms-code.vo';
import { Phone } from '../../domain/value-objects/phone.vo';
@Injectable()
export class SmsService {
private readonly logger = new Logger('SmsService');
private readonly codeExpireSeconds: number;
private readonly dailyLimit: number;
private readonly maxAttempts: number;
constructor(
@Inject(SMS_VERIFICATION_REPOSITORY)
private readonly smsVerifRepo: ISmsVerificationRepository,
@Inject(SMS_LOG_REPOSITORY)
private readonly smsLogRepo: ISmsLogRepository,
@Inject(USER_REPOSITORY)
private readonly userRepo: IUserRepository,
@Inject(SMS_PROVIDER)
private readonly smsProvider: ISmsProvider,
private readonly smsCodeCache: SmsCodeService,
) {
this.codeExpireSeconds = parseInt(process.env.SMS_CODE_EXPIRE_SECONDS || '300', 10);
this.dailyLimit = parseInt(process.env.SMS_DAILY_LIMIT || '10', 10);
this.maxAttempts = parseInt(process.env.SMS_MAX_VERIFY_ATTEMPTS || '5', 10);
}
/**
*
*/
async sendCode(
rawPhone: string,
type: SmsVerificationType,
): Promise<{ expiresIn: number }> {
const phone = Phone.create(rawPhone);
// 1. 检查日发送限额
const dailyCount = await this.smsVerifRepo.getDailySendCount(phone.value);
if (dailyCount >= this.dailyLimit) {
throw new BadRequestException('今日发送次数已达上限,请明天再试');
}
// 2. 按类型验证业务规则
await this.validateSendRequest(phone, type);
// 3. 生成验证码
const code = SmsCode.generate();
const expiresAt = new Date(Date.now() + this.codeExpireSeconds * 1000);
// 4. 持久化到 DB
await this.smsVerifRepo.create({
phone: phone.value,
code: code.value,
type,
expiresAt,
});
// 5. 缓存到 Redis (快速查找)
await this.smsCodeCache.setCode(phone.value, code.value, type, this.codeExpireSeconds);
// 6. 通过 Provider 发送
const result = await this.smsProvider.send(phone.value, code.value, type);
// 7. 记录发送日志
const user = await this.userRepo.findByPhone(phone.value);
await this.smsLogRepo.create({
phone: phone.value,
type,
status: result.success ? SmsDeliveryStatus.SENT : SmsDeliveryStatus.FAILED,
provider: result.providerId ? 'aliyun' : 'console',
providerId: result.providerId,
errorMsg: result.errorMsg,
userId: user?.id,
});
this.logger.log(`SMS code sent: phone=${phone.masked} type=${type}`);
return { expiresIn: this.codeExpireSeconds };
}
/**
*
*/
async verifyCode(
rawPhone: string,
code: string,
type: SmsVerificationType,
): Promise<boolean> {
const phone = Phone.create(rawPhone);
// 先尝试 Redis 快速路径
const redisMatch = await this.smsCodeCache.verifyAndDelete(phone.value, code, type);
if (redisMatch) {
// Redis 匹配成功,同步标记 DB 记录
const dbRecord = await this.smsVerifRepo.findLatestValid(phone.value, type);
if (dbRecord) {
dbRecord.markVerified();
await this.smsVerifRepo.save(dbRecord);
}
return true;
}
// Redis miss → 回退到 DB 验证
const verification = await this.smsVerifRepo.findLatestValid(phone.value, type);
if (!verification) {
throw new BadRequestException('验证码已过期或不存在');
}
if (!verification.canAttempt(this.maxAttempts)) {
throw new BadRequestException('验证码尝试次数过多,请重新获取');
}
if (!SmsCode.from(verification.code).matches(code)) {
verification.incrementAttempts();
await this.smsVerifRepo.save(verification);
throw new BadRequestException('验证码错误');
}
verification.markVerified();
await this.smsVerifRepo.save(verification);
return true;
}
/**
*
*/
private async validateSendRequest(
phone: Phone,
type: SmsVerificationType,
): Promise<void> {
const existingUser = await this.userRepo.findByPhone(phone.value);
switch (type) {
case SmsVerificationType.REGISTER:
if (existingUser) {
throw new BadRequestException('该手机号已注册');
}
break;
case SmsVerificationType.LOGIN:
case SmsVerificationType.RESET_PASSWORD:
if (!existingUser) {
throw new BadRequestException('该手机号未注册');
}
break;
case SmsVerificationType.CHANGE_PHONE:
if (existingUser) {
throw new BadRequestException('该手机号已被其他账户使用');
}
break;
}
}
}

View File

@ -6,21 +6,33 @@ import { PassportModule } from '@nestjs/passport';
// Domain entities
import { User } from './domain/entities/user.entity';
import { RefreshToken } from './domain/entities/refresh-token.entity';
import { SmsVerification } from './domain/entities/sms-verification.entity';
import { SmsLog } from './domain/entities/sms-log.entity';
// Domain repository interfaces
import { USER_REPOSITORY } from './domain/repositories/user.repository.interface';
import { REFRESH_TOKEN_REPOSITORY } from './domain/repositories/refresh-token.repository.interface';
import { SMS_VERIFICATION_REPOSITORY } from './domain/repositories/sms-verification.repository.interface';
import { SMS_LOG_REPOSITORY } from './domain/repositories/sms-log.repository.interface';
// Infrastructure implementations
import { UserRepository } from './infrastructure/persistence/user.repository';
import { RefreshTokenRepository } from './infrastructure/persistence/refresh-token.repository';
import { SmsVerificationRepository } from './infrastructure/persistence/sms-verification.repository';
import { SmsLogRepository } from './infrastructure/persistence/sms-log.repository';
import { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
import { TokenBlacklistService } from './infrastructure/redis/token-blacklist.service';
import { SmsCodeService } from './infrastructure/redis/sms-code.service';
// SMS Provider
import { SMS_PROVIDER } from './infrastructure/sms/sms-provider.interface';
import { ConsoleSmsProvider } from './infrastructure/sms/console-sms.provider';
import { AliyunSmsProvider } from './infrastructure/sms/aliyun-sms.provider';
// Application services
import { AuthService } from './application/services/auth.service';
import { TokenService } from './application/services/token.service';
import { SmsService } from './application/services/sms.service';
import { EventPublisherService } from './application/services/event-publisher.service';
// Interface controllers
@ -28,7 +40,7 @@ import { AuthController } from './interface/http/controllers/auth.controller';
@Module({
imports: [
TypeOrmModule.forFeature([User, RefreshToken]),
TypeOrmModule.forFeature([User, RefreshToken, SmsVerification, SmsLog]),
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret',
@ -40,6 +52,17 @@ import { AuthController } from './interface/http/controllers/auth.controller';
// Infrastructure -> Domain port binding
{ provide: USER_REPOSITORY, useClass: UserRepository },
{ provide: REFRESH_TOKEN_REPOSITORY, useClass: RefreshTokenRepository },
{ provide: SMS_VERIFICATION_REPOSITORY, useClass: SmsVerificationRepository },
{ provide: SMS_LOG_REPOSITORY, useClass: SmsLogRepository },
// SMS Provider: toggle by SMS_ENABLED env var
{
provide: SMS_PROVIDER,
useClass:
process.env.SMS_ENABLED === 'true'
? AliyunSmsProvider
: ConsoleSmsProvider,
},
// Infrastructure
JwtStrategy,
@ -49,8 +72,9 @@ import { AuthController } from './interface/http/controllers/auth.controller';
// Application services
AuthService,
TokenService,
SmsService,
EventPublisherService,
],
exports: [AuthService, TokenService],
exports: [AuthService, TokenService, SmsService],
})
export class AuthModule {}

View File

@ -0,0 +1,47 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
Index,
} from 'typeorm';
import { SmsVerificationType } from './sms-verification.entity';
export enum SmsDeliveryStatus {
PENDING = 'PENDING',
SENT = 'SENT',
DELIVERED = 'DELIVERED',
FAILED = 'FAILED',
}
@Entity('sms_logs')
@Index('idx_sms_logs_phone', ['phone'])
@Index('idx_sms_logs_created', ['createdAt'])
export class SmsLog {
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
id: string;
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string | null;
@Column({ type: 'varchar', length: 20 })
phone: string;
@Column({ type: 'varchar', length: 20 })
type: SmsVerificationType;
@Column({ type: 'varchar', length: 20, default: SmsDeliveryStatus.PENDING })
status: SmsDeliveryStatus;
@Column({ type: 'varchar', length: 50, nullable: true })
provider: string | null;
@Column({ name: 'provider_id', type: 'varchar', length: 100, nullable: true })
providerId: string | null;
@Column({ name: 'error_msg', type: 'text', nullable: true })
errorMsg: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@ -0,0 +1,69 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
Index,
} from 'typeorm';
export enum SmsVerificationType {
REGISTER = 'REGISTER',
LOGIN = 'LOGIN',
RESET_PASSWORD = 'RESET_PASSWORD',
CHANGE_PHONE = 'CHANGE_PHONE',
}
@Entity('sms_verifications')
@Index('idx_sms_verif_phone_type', ['phone', 'type'])
@Index('idx_sms_verif_expires', ['expiresAt'])
export class SmsVerification {
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
id: string; // bigint as string in TypeORM
@Column({ type: 'varchar', length: 20 })
phone: string;
@Column({ type: 'varchar', length: 255 })
code: string;
@Column({ type: 'varchar', length: 20 })
type: SmsVerificationType;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt: Date;
@Column({ name: 'verified_at', type: 'timestamptz', nullable: true })
verifiedAt: Date | null;
@Column({ type: 'int', default: 0 })
attempts: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
/* ── Domain Methods ── */
get isExpired(): boolean {
return new Date() > this.expiresAt;
}
get isVerified(): boolean {
return this.verifiedAt !== null;
}
get isValid(): boolean {
return !this.isExpired && !this.isVerified;
}
canAttempt(maxAttempts: number): boolean {
return this.attempts < maxAttempts;
}
incrementAttempts(): void {
this.attempts += 1;
}
markVerified(): void {
this.verifiedAt = new Date();
}
}

View File

@ -76,6 +76,12 @@ export class User {
@Column({ type: 'varchar', length: 5, nullable: true })
nationality: string | null;
@Column({ name: 'login_fail_count', type: 'int', default: 0 })
loginFailCount: number;
@Column({ name: 'locked_until', type: 'timestamptz', nullable: true })
lockedUntil: Date | null;
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
lastLoginAt: Date | null;
@ -87,4 +93,57 @@ export class User {
@VersionColumn({ default: 1 })
version: number;
/* ── Domain Methods: Account Lockout ── */
/** 账号是否处于锁定状态 */
get isLocked(): boolean {
return this.lockedUntil !== null && new Date() < this.lockedUntil;
}
/** 是否可以登录 (状态正常且未锁定) */
get canLogin(): boolean {
return this.status === UserStatus.ACTIVE && !this.isLocked;
}
/** 剩余锁定秒数 (0 = 未锁定) */
get lockRemainingSeconds(): number {
if (!this.lockedUntil) return 0;
const diff = this.lockedUntil.getTime() - Date.now();
return diff > 0 ? Math.ceil(diff / 1000) : 0;
}
/**
*
*/
recordLoginSuccess(ip?: string): void {
this.loginFailCount = 0;
this.lockedUntil = null;
this.lastLoginAt = new Date();
}
/**
* 退
* @param maxAttempts ( 6)
* @returns &
*/
recordLoginFailure(maxAttempts = 6): {
remainingAttempts: number;
lockMinutes?: number;
} {
this.loginFailCount += 1;
if (this.loginFailCount < maxAttempts) {
return { remainingAttempts: maxAttempts - this.loginFailCount };
}
// 指数退避: 2^(failCount - maxAttempts) 分钟,最大 1440 分钟 (24h)
const lockMinutes = Math.min(
Math.pow(2, this.loginFailCount - maxAttempts),
1440,
);
this.lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000);
return { remainingAttempts: 0, lockMinutes };
}
}

View File

@ -29,3 +29,37 @@ export interface PasswordChangedEvent {
userId: string;
timestamp: string;
}
/* ── SMS & Account Lock Events ── */
export interface SmsCodeSentEvent {
phone: string;
type: string; // SmsVerificationType
timestamp: string;
}
export interface SmsCodeVerifiedEvent {
phone: string;
type: string;
timestamp: string;
}
export interface AccountLockedEvent {
userId: string;
lockedUntil: string;
failCount: number;
timestamp: string;
}
export interface PhoneChangedEvent {
userId: string;
oldPhone: string;
newPhone: string;
timestamp: string;
}
export interface PasswordResetEvent {
userId: string;
phone: string;
timestamp: string;
}

View File

@ -0,0 +1,32 @@
import { SmsLog } from '../entities/sms-log.entity';
import { SmsVerificationType } from '../entities/sms-verification.entity';
import { SmsDeliveryStatus } from '../entities/sms-log.entity';
export interface ISmsLogRepository {
/** 记录发送日志 */
create(data: {
phone: string;
type: SmsVerificationType;
status: SmsDeliveryStatus;
provider?: string;
providerId?: string;
errorMsg?: string;
userId?: string;
}): Promise<SmsLog>;
/** 按手机号查询日志 */
findByPhone(
phone: string,
options?: { limit?: number; offset?: number },
): Promise<SmsLog[]>;
/** 更新发送状态 */
updateStatus(
id: string,
status: SmsDeliveryStatus,
providerId?: string,
errorMsg?: string,
): Promise<void>;
}
export const SMS_LOG_REPOSITORY = Symbol('ISmsLogRepository');

View File

@ -0,0 +1,30 @@
import { SmsVerification, SmsVerificationType } from '../entities/sms-verification.entity';
export interface ISmsVerificationRepository {
/** 创建验证记录 */
create(data: {
phone: string;
code: string;
type: SmsVerificationType;
expiresAt: Date;
}): Promise<SmsVerification>;
/** 查找指定手机号和类型的最新有效验证码 (未过期 + 未验证) */
findLatestValid(
phone: string,
type: SmsVerificationType,
): Promise<SmsVerification | null>;
/** 保存 (更新 attempts / verifiedAt) */
save(verification: SmsVerification): Promise<SmsVerification>;
/** 获取今日发送次数 */
getDailySendCount(phone: string): Promise<number>;
/** 清理过期记录,返回删除数量 */
deleteExpired(): Promise<number>;
}
export const SMS_VERIFICATION_REPOSITORY = Symbol(
'ISmsVerificationRepository',
);

View File

@ -0,0 +1,72 @@
/**
* Phone Value Object
* + E.164 +
*/
export class Phone {
/** 中国大陆手机号 (不带区号) */
private static readonly CN_PATTERN = /^1[3-9]\d{9}$/;
/** E.164 国际格式 */
private static readonly E164_PATTERN = /^\+[1-9]\d{6,14}$/;
private constructor(public readonly value: string) {}
/**
* Phone E.164
* - `13800138000` `+8613800138000`
* - `+8613800138000` `+8613800138000`
*/
static create(raw: string): Phone {
const trimmed = raw.trim();
// 已经是 E.164 格式
if (Phone.E164_PATTERN.test(trimmed)) {
return new Phone(trimmed);
}
// 中国大陆格式,自动补 +86
if (Phone.CN_PATTERN.test(trimmed)) {
return new Phone(`+86${trimmed}`);
}
throw new Error(`手机号格式无效: ${trimmed}`);
}
/**
* E.164
*/
static fromStored(value: string): Phone {
return new Phone(value);
}
/**
* : +86138****8000
*/
get masked(): string {
if (this.value.startsWith('+86') && this.value.length === 14) {
const local = this.value.slice(3); // 13800138000
return `${local.slice(0, 3)}****${local.slice(7)}`;
}
// 通用掩码保留前4后4
const len = this.value.length;
if (len <= 8) return this.value;
return `${this.value.slice(0, 4)}${'*'.repeat(len - 8)}${this.value.slice(-4)}`;
}
/**
* ()
*/
get local(): string {
if (this.value.startsWith('+86')) {
return this.value.slice(3);
}
return this.value;
}
equals(other: Phone): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}

View File

@ -0,0 +1,41 @@
import { randomInt } from 'crypto';
/**
* SmsCode Value Object
* +
*/
export class SmsCode {
static readonly LENGTH = 6;
private static readonly PATTERN = /^\d{6}$/;
private constructor(public readonly value: string) {}
/**
* 使 crypto 6
*/
static generate(): SmsCode {
const code = randomInt(0, 1_000_000).toString().padStart(SmsCode.LENGTH, '0');
return new SmsCode(code);
}
/**
*
*/
static from(raw: string): SmsCode {
if (!SmsCode.PATTERN.test(raw)) {
throw new Error(`验证码格式无效: 必须为${SmsCode.LENGTH}位数字`);
}
return new SmsCode(raw);
}
/**
*
*/
matches(input: string): boolean {
return this.value === input;
}
toString(): string {
return this.value;
}
}

View File

@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SmsLog, SmsDeliveryStatus } from '../../domain/entities/sms-log.entity';
import { SmsVerificationType } from '../../domain/entities/sms-verification.entity';
import { ISmsLogRepository } from '../../domain/repositories/sms-log.repository.interface';
@Injectable()
export class SmsLogRepository implements ISmsLogRepository {
constructor(
@InjectRepository(SmsLog)
private readonly repo: Repository<SmsLog>,
) {}
async create(data: {
phone: string;
type: SmsVerificationType;
status: SmsDeliveryStatus;
provider?: string;
providerId?: string;
errorMsg?: string;
userId?: string;
}): Promise<SmsLog> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async findByPhone(
phone: string,
options?: { limit?: number; offset?: number },
): Promise<SmsLog[]> {
return this.repo.find({
where: { phone },
order: { createdAt: 'DESC' },
take: options?.limit || 20,
skip: options?.offset || 0,
});
}
async updateStatus(
id: string,
status: SmsDeliveryStatus,
providerId?: string,
errorMsg?: string,
): Promise<void> {
await this.repo.update(id, {
status,
...(providerId && { providerId }),
...(errorMsg && { errorMsg }),
});
}
}

View File

@ -0,0 +1,67 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThan, IsNull } from 'typeorm';
import { SmsVerification, SmsVerificationType } from '../../domain/entities/sms-verification.entity';
import { ISmsVerificationRepository } from '../../domain/repositories/sms-verification.repository.interface';
@Injectable()
export class SmsVerificationRepository implements ISmsVerificationRepository {
constructor(
@InjectRepository(SmsVerification)
private readonly repo: Repository<SmsVerification>,
) {}
async create(data: {
phone: string;
code: string;
type: SmsVerificationType;
expiresAt: Date;
}): Promise<SmsVerification> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async findLatestValid(
phone: string,
type: SmsVerificationType,
): Promise<SmsVerification | null> {
return this.repo.findOne({
where: {
phone,
type,
expiresAt: MoreThan(new Date()),
verifiedAt: IsNull(),
},
order: { createdAt: 'DESC' },
});
}
async save(verification: SmsVerification): Promise<SmsVerification> {
return this.repo.save(verification);
}
async getDailySendCount(phone: string): Promise<number> {
const today = new Date();
today.setHours(0, 0, 0, 0);
return this.repo.count({
where: {
phone,
createdAt: MoreThan(today),
},
});
}
async deleteExpired(): Promise<number> {
const result = await this.repo
.createQueryBuilder()
.delete()
.where('expires_at < :now', { now: new Date() })
.andWhere('verified_at IS NOT NULL OR expires_at < :cutoff', {
cutoff: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24h grace
})
.execute();
return result.affected || 0;
}
}

View File

@ -1,10 +1,11 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
import { SmsVerificationType } from '../../domain/entities/sms-verification.entity';
/**
* SMS code storage service using Redis.
* Stores 6-digit verification codes with a 5-minute TTL.
* In dev mode, codes are logged to console instead of sent via SMS.
* SMS code Redis cache service.
* DB
* Key pattern: auth:sms:{type}:{phone}
*/
@Injectable()
export class SmsCodeService implements OnModuleInit, OnModuleDestroy {
@ -35,28 +36,64 @@ export class SmsCodeService implements OnModuleInit, OnModuleDestroy {
}
/**
* Generate and store a 6-digit code for the given phone number.
* TTL is 5 minutes (300 seconds).
* Redis
* @param phone E.164
* @param code 6
* @param type
* @param ttlSeconds ( 300)
*/
async setCode(
phone: string,
code: string,
type: SmsVerificationType,
ttlSeconds = 300,
): Promise<void> {
const key = `${type}:${phone}`;
await this.redis.set(key, code, 'EX', ttlSeconds);
}
/**
* Redis
* @returns true=, false=
*/
async verifyAndDelete(
phone: string,
code: string,
type: SmsVerificationType,
): Promise<boolean> {
const key = `${type}:${phone}`;
const stored = await this.redis.get(key);
if (!stored || stored !== code) {
return false;
}
await this.redis.del(key);
return true;
}
/**
*
*/
async deleteCode(phone: string, type: SmsVerificationType): Promise<void> {
const key = `${type}:${phone}`;
await this.redis.del(key);
}
/* ── Legacy compatibility (used by existing auth.service) ── */
/** @deprecated Use setCode() with type parameter */
async generateCode(phone: string): Promise<string> {
const code = String(Math.floor(100000 + Math.random() * 900000));
await this.redis.set(phone, code, 'EX', 300);
// In dev mode, log the code instead of sending a real SMS
this.logger.log(`[DEV] SMS code for ${phone}: ${code}`);
return code;
}
/**
* Verify the code for the given phone number.
* Returns true if valid, false otherwise.
* On successful verification, the code is deleted to prevent reuse.
*/
/** @deprecated Use verifyAndDelete() with type parameter */
async verifyCode(phone: string, code: string): Promise<boolean> {
const stored = await this.redis.get(phone);
if (!stored || stored !== code) {
return false;
}
// Delete the code after successful verification
await this.redis.del(phone);
return true;
}

View File

@ -0,0 +1,88 @@
import { Injectable, Logger } from '@nestjs/common';
import { SmsVerificationType } from '../../domain/entities/sms-verification.entity';
import { ISmsProvider, SmsDeliveryResult } from './sms-provider.interface';
/**
* SMS Provider
*
* 使 (dysmsapi)
* 需要安装: npm install @alicloud/dysmsapi20170525 @alicloud/openapi-client
*
* :
* - ALIYUN_ACCESS_KEY_ID
* - ALIYUN_ACCESS_KEY_SECRET
* - ALIYUN_SMS_SIGN_NAME ()
* - ALIYUN_SMS_TEMPLATE_CODE ()
* - ALIYUN_SMS_ENDPOINT ( dysmsapi.aliyuncs.com)
*/
@Injectable()
export class AliyunSmsProvider implements ISmsProvider {
private readonly logger = new Logger('AliyunSmsProvider');
private client: any; // Dysmsapi20170525 client (lazy init)
async send(
phone: string,
code: string,
type: SmsVerificationType,
): Promise<SmsDeliveryResult> {
try {
const client = await this.getClient();
// 去掉 +86 前缀 (阿里云要求纯数字)
const phoneNumber = phone.startsWith('+86') ? phone.slice(3) : phone;
const templateParam = JSON.stringify({ code });
const signName = process.env.ALIYUN_SMS_SIGN_NAME || '券金融';
const templateCode = this.getTemplateCode(type);
const result = await client.sendSms({
phoneNumbers: phoneNumber,
signName,
templateCode,
templateParam,
});
if (result.body?.code === 'OK') {
return {
success: true,
providerId: result.body.bizId,
};
}
this.logger.error(
`Aliyun SMS failed: ${result.body?.code} - ${result.body?.message}`,
);
return {
success: false,
errorMsg: `${result.body?.code}: ${result.body?.message}`,
};
} catch (error: any) {
this.logger.error(`Aliyun SMS error: ${error.message}`);
return { success: false, errorMsg: error.message };
}
}
private getTemplateCode(type: SmsVerificationType): string {
// 可为不同类型配置不同模板,默认使用通用模板
return process.env.ALIYUN_SMS_TEMPLATE_CODE || '';
}
private async getClient() {
if (this.client) return this.client;
// 动态导入 (仅生产环境需要)
const { default: Dysmsapi20170525 } = await import(
'@alicloud/dysmsapi20170525'
);
const { Config } = await import('@alicloud/openapi-client');
const config = new Config({
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID,
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET,
endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com',
});
this.client = new Dysmsapi20170525(config);
return this.client;
}
}

View File

@ -0,0 +1,22 @@
import { Injectable, Logger } from '@nestjs/common';
import { SmsVerificationType } from '../../domain/entities/sms-verification.entity';
import { ISmsProvider, SmsDeliveryResult } from './sms-provider.interface';
/**
* SMS Provider
*/
@Injectable()
export class ConsoleSmsProvider implements ISmsProvider {
private readonly logger = new Logger('ConsoleSmsProvider');
async send(
phone: string,
code: string,
type: SmsVerificationType,
): Promise<SmsDeliveryResult> {
this.logger.warn(
`[DEV SMS] phone=${phone} code=${code} type=${type}`,
);
return { success: true, providerId: `console-${Date.now()}` };
}
}

View File

@ -0,0 +1,23 @@
import { SmsVerificationType } from '../../domain/entities/sms-verification.entity';
export interface SmsDeliveryResult {
success: boolean;
providerId?: string;
errorMsg?: string;
}
export interface ISmsProvider {
/**
*
* @param phone E.164
* @param code 6
* @param type
*/
send(
phone: string,
code: string,
type: SmsVerificationType,
): Promise<SmsDeliveryResult>;
}
export const SMS_PROVIDER = Symbol('ISmsProvider');

View File

@ -10,6 +10,7 @@ import {
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { ThrottlerGuard } from '@nestjs/throttler';
import { AuthService } from '../../../application/services/auth.service';
import { RegisterDto } from '../dto/register.dto';
import { LoginDto } from '../dto/login.dto';
@ -17,30 +18,55 @@ import { RefreshTokenDto } from '../dto/refresh-token.dto';
import { ChangePasswordDto } from '../dto/change-password.dto';
import { SendSmsCodeDto } from '../dto/send-sms-code.dto';
import { LoginPhoneDto } from '../dto/login-phone.dto';
import { ResetPasswordDto } from '../dto/reset-password.dto';
import { ChangePhoneDto } from '../dto/change-phone.dto';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
/* ── SMS 验证码 ── */
@Post('sms/send')
@HttpCode(HttpStatus.OK)
@UseGuards(ThrottlerGuard)
@ApiOperation({ summary: '发送短信验证码' })
@ApiResponse({ status: 200, description: '验证码发送成功' })
@ApiResponse({ status: 400, description: '手机号无效 / 日发送限额已满' })
async sendSmsCode(@Body() dto: SendSmsCodeDto) {
const result = await this.authService.sendSmsCode(dto.phone, dto.type);
return {
code: 0,
data: result,
message: '验证码发送成功',
};
}
/* ── 注册 ── */
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@ApiResponse({ status: 201, description: 'User registered successfully' })
@ApiResponse({ status: 409, description: 'Phone/email already exists' })
@ApiOperation({ summary: '手机号注册 (需先获取验证码)' })
@ApiResponse({ status: 201, description: '注册成功' })
@ApiResponse({ status: 400, description: '验证码错误' })
@ApiResponse({ status: 409, description: '手机号已注册' })
async register(@Body() dto: RegisterDto) {
const result = await this.authService.register(dto);
return {
code: 0,
data: result,
message: 'Registration successful',
message: '注册成功',
};
}
/* ── 密码登录 ── */
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login with phone/email and password' })
@ApiResponse({ status: 200, description: 'Login successful' })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
@ApiOperation({ summary: '密码登录 (手机号/邮箱 + 密码)' })
@ApiResponse({ status: 200, description: '登录成功' })
@ApiResponse({ status: 401, description: '账号或密码错误' })
@ApiResponse({ status: 403, description: '账号已锁定/冻结' })
async login(@Body() dto: LoginDto, @Ip() ip: string) {
const result = await this.authService.login({
...dto,
@ -49,77 +75,111 @@ export class AuthController {
return {
code: 0,
data: result,
message: 'Login successful',
message: '登录成功',
};
}
/* ── 短信验证码登录 ── */
@Post('login-phone')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '短信验证码登录' })
@ApiResponse({ status: 200, description: '登录成功' })
@ApiResponse({ status: 401, description: '验证码错误或手机号未注册' })
async loginWithPhone(@Body() dto: LoginPhoneDto, @Ip() ip: string) {
const result = await this.authService.loginWithPhone(
dto.phone,
dto.smsCode,
dto.deviceInfo,
ip,
);
return {
code: 0,
data: result,
message: '登录成功',
};
}
/* ── 重置密码 ── */
@Post('reset-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '重置密码 (手机号 + 验证码 + 新密码)' })
@ApiResponse({ status: 200, description: '密码重置成功' })
@ApiResponse({ status: 400, description: '验证码错误或手机号未注册' })
async resetPassword(@Body() dto: ResetPasswordDto) {
await this.authService.resetPassword(dto.phone, dto.smsCode, dto.newPassword);
return {
code: 0,
data: null,
message: '密码重置成功,请重新登录',
};
}
/* ── 更换手机号 ── */
@Post('change-phone')
@HttpCode(HttpStatus.OK)
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: '更换绑定手机号 (需登录)' })
@ApiResponse({ status: 200, description: '手机号更换成功' })
@ApiResponse({ status: 400, description: '验证码错误' })
@ApiResponse({ status: 409, description: '新手机号已被使用' })
async changePhone(@Req() req: any, @Body() dto: ChangePhoneDto) {
await this.authService.changePhone(req.user.id, dto.newPhone, dto.newSmsCode);
return {
code: 0,
data: null,
message: '手机号更换成功',
};
}
/* ── Token 刷新 ── */
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token using refresh token' })
@ApiResponse({ status: 200, description: 'Token refreshed' })
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
@ApiOperation({ summary: '刷新 access token' })
@ApiResponse({ status: 200, description: 'Token 刷新成功' })
@ApiResponse({ status: 401, description: 'Refresh token 无效' })
async refresh(@Body() dto: RefreshTokenDto) {
const tokens = await this.authService.refreshToken(dto.refreshToken);
return {
code: 0,
data: tokens,
message: 'Token refreshed',
message: 'Token 刷新成功',
};
}
/* ── 登出 ── */
@Post('logout')
@HttpCode(HttpStatus.OK)
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: 'Logout - revoke all refresh tokens' })
@ApiOperation({ summary: '登出 (撤销所有 refresh token)' })
async logout(@Req() req: any) {
await this.authService.logout(req.user.id);
return {
code: 0,
data: null,
message: 'Logged out successfully',
message: '已登出',
};
}
/* ── 修改密码 ── */
@Post('change-password')
@HttpCode(HttpStatus.OK)
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: 'Change password' })
@ApiOperation({ summary: '修改密码 (需登录)' })
async changePassword(@Req() req: any, @Body() dto: ChangePasswordDto) {
await this.authService.changePassword(req.user.id, dto.oldPassword, dto.newPassword);
return {
code: 0,
data: null,
message: 'Password changed successfully',
};
}
@Post('send-sms-code')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Send SMS verification code to phone number' })
@ApiResponse({ status: 200, description: 'SMS code sent successfully' })
@ApiResponse({ status: 400, description: 'Invalid phone number' })
async sendSmsCode(@Body() dto: SendSmsCodeDto) {
await this.authService.sendSmsCode(dto.phone);
return {
code: 0,
data: { success: true },
message: 'SMS code sent successfully',
};
}
@Post('login-phone')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login with phone number and SMS verification code' })
@ApiResponse({ status: 200, description: 'Login successful' })
@ApiResponse({ status: 401, description: 'Invalid or expired SMS code' })
async loginWithPhone(@Body() dto: LoginPhoneDto, @Ip() ip: string) {
const result = await this.authService.loginWithPhone(dto.phone, dto.smsCode, ip);
return {
code: 0,
data: result,
message: 'Login successful',
message: '密码修改成功',
};
}
}

View File

@ -0,0 +1,14 @@
import { IsString, IsNotEmpty, Length } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ChangePhoneDto {
@ApiProperty({ description: '新手机号', example: '+8613900139000' })
@IsString()
@IsNotEmpty()
newPhone: string;
@ApiProperty({ description: '新手机号的6位短信验证码', example: '123456' })
@IsString()
@Length(6, 6)
newSmsCode: string;
}

View File

@ -1,14 +1,19 @@
import { IsString, IsNotEmpty, Length } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional, Length } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class LoginPhoneDto {
@ApiProperty({ description: 'Phone number', example: '+8613800138000' })
@ApiProperty({ description: '手机号', example: '+8613800138000' })
@IsString()
@IsNotEmpty()
phone: string;
@ApiProperty({ description: '6-digit SMS verification code', example: '123456' })
@ApiProperty({ description: '6位短信验证码', example: '123456' })
@IsString()
@Length(6, 6)
smsCode: string;
@ApiPropertyOptional({ description: '设备信息' })
@IsOptional()
@IsString()
deviceInfo?: string;
}

View File

@ -1,25 +1,24 @@
import { IsString, IsOptional, IsEmail, MinLength, MaxLength, Matches } from 'class-validator';
import { IsString, IsOptional, MinLength, MaxLength, Matches, Length } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto {
@ApiPropertyOptional({ example: '+8613800138000' })
@IsOptional()
@ApiProperty({ description: '手机号 (E.164 或大陆格式)', example: '+8613800138000' })
@IsString()
@Matches(/^\+?[1-9]\d{6,14}$/, { message: 'Invalid phone number format' })
phone?: string;
@Matches(/^\+?[1-9]\d{6,14}$/, { message: '手机号格式无效' })
phone: string;
@ApiPropertyOptional({ example: 'user@example.com' })
@IsOptional()
@IsEmail()
email?: string;
@ApiProperty({ description: '6位短信验证码', example: '123456' })
@IsString()
@Length(6, 6, { message: '验证码必须为6位数字' })
smsCode: string;
@ApiProperty({ example: 'Password123!', minLength: 8 })
@ApiProperty({ description: '登录密码 (8-128位)', example: 'Password123!' })
@IsString()
@MinLength(8)
@MaxLength(128)
password: string;
@ApiPropertyOptional({ example: 'John' })
@ApiPropertyOptional({ description: '昵称', example: 'John' })
@IsOptional()
@IsString()
@MaxLength(50)

View File

@ -0,0 +1,20 @@
import { IsString, IsNotEmpty, MinLength, MaxLength, Length } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ResetPasswordDto {
@ApiProperty({ description: '手机号', example: '+8613800138000' })
@IsString()
@IsNotEmpty()
phone: string;
@ApiProperty({ description: '6位短信验证码', example: '123456' })
@IsString()
@Length(6, 6)
smsCode: string;
@ApiProperty({ description: '新密码 (8-128位)', example: 'NewPassword456!' })
@IsString()
@MinLength(8)
@MaxLength(128)
newPassword: string;
}

View File

@ -1,9 +1,18 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { IsString, IsNotEmpty, IsEnum } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { SmsVerificationType } from '../../../domain/entities/sms-verification.entity';
export class SendSmsCodeDto {
@ApiProperty({ description: 'Phone number to send SMS code to', example: '+8613800138000' })
@ApiProperty({ description: '手机号', example: '+8613800138000' })
@IsString()
@IsNotEmpty()
phone: string;
@ApiProperty({
description: '验证码类型',
enum: SmsVerificationType,
example: SmsVerificationType.LOGIN,
})
@IsEnum(SmsVerificationType, { message: '无效的验证码类型' })
type: SmsVerificationType;
}