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:
parent
2ff8b48e50
commit
e89ec82406
|
|
@ -50,6 +50,24 @@ MINIO_ACCESS_KEY=genex-admin
|
||||||
MINIO_SECRET_KEY=genex-minio-secret
|
MINIO_SECRET_KEY=genex-minio-secret
|
||||||
MINIO_USE_SSL=false
|
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) ---
|
# --- External Services (all mocked in MVP) ---
|
||||||
CHAIN_RPC_URL=http://localhost:26657
|
CHAIN_RPC_URL=http://localhost:26657
|
||||||
SENDGRID_API_KEY=mock-key
|
SENDGRID_API_KEY=mock-key
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -1,16 +1,25 @@
|
||||||
import { Injectable, Logger, UnauthorizedException, ConflictException, ForbiddenException, BadRequestException } from '@nestjs/common';
|
import {
|
||||||
import { Inject } from '@nestjs/common';
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
UnauthorizedException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
BadRequestException,
|
||||||
|
Inject,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface';
|
import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface';
|
||||||
import { REFRESH_TOKEN_REPOSITORY, IRefreshTokenRepository } from '../../domain/repositories/refresh-token.repository.interface';
|
import { REFRESH_TOKEN_REPOSITORY, IRefreshTokenRepository } from '../../domain/repositories/refresh-token.repository.interface';
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
|
import { SmsService } from './sms.service';
|
||||||
import { Password } from '../../domain/value-objects/password.vo';
|
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 { UserRole, UserStatus } from '../../domain/entities/user.entity';
|
||||||
|
import { SmsVerificationType } from '../../domain/entities/sms-verification.entity';
|
||||||
import { EventPublisherService } from './event-publisher.service';
|
import { EventPublisherService } from './event-publisher.service';
|
||||||
import { SmsCodeService } from '../../infrastructure/redis/sms-code.service';
|
|
||||||
|
|
||||||
export interface RegisterDto {
|
export interface RegisterDto {
|
||||||
phone?: string;
|
phone: string;
|
||||||
email?: string;
|
smsCode: string;
|
||||||
password: string;
|
password: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -28,13 +37,16 @@ export interface AuthTokens {
|
||||||
expiresIn: number;
|
expiresIn: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterResult {
|
export interface AuthResult {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
nickname: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
kycLevel: number;
|
kycLevel: number;
|
||||||
|
walletMode: string;
|
||||||
};
|
};
|
||||||
tokens: AuthTokens;
|
tokens: AuthTokens;
|
||||||
}
|
}
|
||||||
|
|
@ -47,33 +59,31 @@ export class AuthService {
|
||||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||||
@Inject(REFRESH_TOKEN_REPOSITORY) private readonly refreshTokenRepo: IRefreshTokenRepository,
|
@Inject(REFRESH_TOKEN_REPOSITORY) private readonly refreshTokenRepo: IRefreshTokenRepository,
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
|
private readonly smsService: SmsService,
|
||||||
private readonly eventPublisher: EventPublisherService,
|
private readonly eventPublisher: EventPublisherService,
|
||||||
private readonly smsCodeService: SmsCodeService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(dto: RegisterDto): Promise<RegisterResult> {
|
/* ── Register ── */
|
||||||
// Validate at least one identifier
|
|
||||||
if (!dto.phone && !dto.email) {
|
async register(dto: RegisterDto): Promise<AuthResult> {
|
||||||
throw new ConflictException('Phone or email is required');
|
const phone = Phone.create(dto.phone);
|
||||||
|
|
||||||
|
// Check duplicate
|
||||||
|
const existing = await this.userRepo.findByPhone(phone.value);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('该手机号已注册');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check duplicates
|
// Verify SMS code
|
||||||
if (dto.phone) {
|
await this.smsService.verifyCode(dto.phone, dto.smsCode, SmsVerificationType.REGISTER);
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash password
|
// Hash password
|
||||||
const password = await Password.create(dto.password);
|
const password = await Password.create(dto.password);
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
const user = await this.userRepo.create({
|
const user = await this.userRepo.create({
|
||||||
phone: dto.phone || null,
|
phone: phone.value,
|
||||||
email: dto.email || null,
|
email: null,
|
||||||
passwordHash: password.value,
|
passwordHash: password.value,
|
||||||
nickname: dto.nickname || null,
|
nickname: dto.nickname || null,
|
||||||
role: UserRole.USER,
|
role: UserRole.USER,
|
||||||
|
|
@ -84,8 +94,6 @@ export class AuthService {
|
||||||
|
|
||||||
// Generate tokens
|
// Generate tokens
|
||||||
const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel);
|
const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel);
|
||||||
|
|
||||||
// Store refresh token
|
|
||||||
await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken);
|
await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken);
|
||||||
|
|
||||||
// Publish event
|
// Publish event
|
||||||
|
|
@ -97,52 +105,51 @@ export class AuthService {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`User registered: ${user.id}`);
|
this.logger.log(`User registered: ${user.id} phone=${phone.masked}`);
|
||||||
|
return { user: this.toUserDto(user), tokens };
|
||||||
return {
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
phone: user.phone,
|
|
||||||
email: user.email,
|
|
||||||
role: user.role,
|
|
||||||
kycLevel: user.kycLevel,
|
|
||||||
},
|
|
||||||
tokens,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(dto: LoginDto): Promise<{ user: any; tokens: AuthTokens }> {
|
/* ── Password Login ── */
|
||||||
// Find user by phone or email
|
|
||||||
|
async login(dto: LoginDto): Promise<AuthResult> {
|
||||||
const user = await this.userRepo.findByPhoneOrEmail(dto.identifier);
|
const user = await this.userRepo.findByPhoneOrEmail(dto.identifier);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException('账号或密码错误');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check status
|
this.checkUserStatus(user);
|
||||||
if (user.status === UserStatus.FROZEN) {
|
this.checkAccountLock(user);
|
||||||
throw new ForbiddenException('Account is frozen');
|
|
||||||
}
|
|
||||||
if (user.status === UserStatus.DELETED) {
|
|
||||||
throw new UnauthorizedException('Account not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
const password = Password.fromHash(user.passwordHash);
|
const password = Password.fromHash(user.passwordHash);
|
||||||
const valid = await password.verify(dto.password);
|
const valid = await password.verify(dto.password);
|
||||||
if (!valid) {
|
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
|
// Success
|
||||||
await this.userRepo.updateLastLogin(user.id);
|
user.recordLoginSuccess(dto.ipAddress);
|
||||||
|
await this.userRepo.save(user);
|
||||||
|
|
||||||
// Generate tokens
|
|
||||||
const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel);
|
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);
|
await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, dto.deviceInfo, dto.ipAddress);
|
||||||
|
|
||||||
// Publish event
|
|
||||||
await this.eventPublisher.publishUserLoggedIn({
|
await this.eventPublisher.publishUserLoggedIn({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
ipAddress: dto.ipAddress || null,
|
ipAddress: dto.ipAddress || null,
|
||||||
|
|
@ -150,47 +157,143 @@ export class AuthService {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { user: this.toUserDto(user), tokens };
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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> {
|
async refreshToken(refreshToken: string): Promise<AuthTokens> {
|
||||||
const payload = await this.tokenService.verifyRefreshToken(refreshToken);
|
const payload = await this.tokenService.verifyRefreshToken(refreshToken);
|
||||||
|
|
||||||
// Fetch user to get current role/kycLevel
|
|
||||||
const user = await this.userRepo.findById(payload.sub);
|
const user = await this.userRepo.findById(payload.sub);
|
||||||
if (!user || user.status !== UserStatus.ACTIVE) {
|
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);
|
await this.tokenService.revokeRefreshToken(refreshToken);
|
||||||
|
|
||||||
// Generate new token pair
|
|
||||||
const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel);
|
const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel);
|
||||||
|
|
||||||
// Store new refresh token
|
|
||||||
await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken);
|
await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken);
|
||||||
|
|
||||||
return tokens;
|
return tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Logout ── */
|
||||||
|
|
||||||
async logout(userId: string): Promise<void> {
|
async logout(userId: string): Promise<void> {
|
||||||
// Revoke all refresh tokens for this user
|
|
||||||
await this.refreshTokenRepo.revokeByUserId(userId);
|
await this.refreshTokenRepo.revokeByUserId(userId);
|
||||||
|
|
||||||
// Publish event
|
|
||||||
await this.eventPublisher.publishUserLoggedOut({
|
await this.eventPublisher.publishUserLoggedOut({
|
||||||
userId,
|
userId,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
|
@ -199,105 +302,56 @@ export class AuthService {
|
||||||
this.logger.log(`User logged out: ${userId}`);
|
this.logger.log(`User logged out: ${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Change Password ── */
|
||||||
|
|
||||||
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
|
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
|
||||||
const user = await this.userRepo.findById(userId);
|
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 currentPassword = Password.fromHash(user.passwordHash);
|
||||||
const valid = await currentPassword.verify(oldPassword);
|
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);
|
const newHash = await Password.create(newPassword);
|
||||||
user.passwordHash = newHash.value;
|
user.passwordHash = newHash.value;
|
||||||
await this.userRepo.save(user);
|
await this.userRepo.save(user);
|
||||||
|
|
||||||
// Revoke all refresh tokens (force re-login)
|
|
||||||
await this.refreshTokenRepo.revokeByUserId(userId);
|
await this.refreshTokenRepo.revokeByUserId(userId);
|
||||||
|
|
||||||
// Publish event
|
|
||||||
await this.eventPublisher.publishPasswordChanged({
|
await this.eventPublisher.publishPasswordChanged({
|
||||||
userId,
|
userId,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/* ── Send SMS Code (delegates to SmsService) ── */
|
||||||
* Send a 6-digit SMS verification code to the given phone number.
|
|
||||||
* In dev mode, the code is logged to console.
|
async sendSmsCode(rawPhone: string, type: SmsVerificationType): Promise<{ expiresIn: number }> {
|
||||||
*/
|
return this.smsService.sendCode(rawPhone, type);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/* ── Private Helpers ── */
|
||||||
* 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find or create user by phone
|
private checkUserStatus(user: any): void {
|
||||||
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
|
|
||||||
if (user.status === UserStatus.FROZEN) {
|
if (user.status === UserStatus.FROZEN) {
|
||||||
throw new ForbiddenException('Account is frozen');
|
throw new ForbiddenException('账号已被冻结');
|
||||||
}
|
}
|
||||||
if (user.status === UserStatus.DELETED) {
|
if (user.status === UserStatus.DELETED) {
|
||||||
throw new UnauthorizedException('Account not found');
|
throw new UnauthorizedException('账号不存在');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last login
|
private checkAccountLock(user: any): void {
|
||||||
await this.userRepo.updateLastLogin(user.id);
|
if (user.isLocked) {
|
||||||
|
const mins = Math.ceil(user.lockRemainingSeconds / 60);
|
||||||
// Generate tokens
|
throw new ForbiddenException(
|
||||||
const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel);
|
`账号已锁定,请 ${mins} 分钟后再试`,
|
||||||
|
);
|
||||||
// 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 toUserDto(user: any) {
|
||||||
return {
|
return {
|
||||||
user: {
|
|
||||||
id: user.id,
|
id: user.id,
|
||||||
phone: user.phone,
|
phone: user.phone,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|
@ -306,8 +360,6 @@ export class AuthService {
|
||||||
role: user.role,
|
role: user.role,
|
||||||
kycLevel: user.kycLevel,
|
kycLevel: user.kycLevel,
|
||||||
walletMode: user.walletMode,
|
walletMode: user.walletMode,
|
||||||
},
|
|
||||||
tokens,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import {
|
||||||
UserLoggedInEvent,
|
UserLoggedInEvent,
|
||||||
UserLoggedOutEvent,
|
UserLoggedOutEvent,
|
||||||
PasswordChangedEvent,
|
PasswordChangedEvent,
|
||||||
|
AccountLockedEvent,
|
||||||
|
PhoneChangedEvent,
|
||||||
|
PasswordResetEvent,
|
||||||
} from '../../domain/events/auth.events';
|
} 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);
|
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(
|
private async publishToOutbox(
|
||||||
topic: string,
|
topic: string,
|
||||||
aggregateType: string,
|
aggregateType: string,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,21 +6,33 @@ import { PassportModule } from '@nestjs/passport';
|
||||||
// Domain entities
|
// Domain entities
|
||||||
import { User } from './domain/entities/user.entity';
|
import { User } from './domain/entities/user.entity';
|
||||||
import { RefreshToken } from './domain/entities/refresh-token.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
|
// Domain repository interfaces
|
||||||
import { USER_REPOSITORY } from './domain/repositories/user.repository.interface';
|
import { USER_REPOSITORY } from './domain/repositories/user.repository.interface';
|
||||||
import { REFRESH_TOKEN_REPOSITORY } from './domain/repositories/refresh-token.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
|
// Infrastructure implementations
|
||||||
import { UserRepository } from './infrastructure/persistence/user.repository';
|
import { UserRepository } from './infrastructure/persistence/user.repository';
|
||||||
import { RefreshTokenRepository } from './infrastructure/persistence/refresh-token.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 { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
|
||||||
import { TokenBlacklistService } from './infrastructure/redis/token-blacklist.service';
|
import { TokenBlacklistService } from './infrastructure/redis/token-blacklist.service';
|
||||||
import { SmsCodeService } from './infrastructure/redis/sms-code.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
|
// Application services
|
||||||
import { AuthService } from './application/services/auth.service';
|
import { AuthService } from './application/services/auth.service';
|
||||||
import { TokenService } from './application/services/token.service';
|
import { TokenService } from './application/services/token.service';
|
||||||
|
import { SmsService } from './application/services/sms.service';
|
||||||
import { EventPublisherService } from './application/services/event-publisher.service';
|
import { EventPublisherService } from './application/services/event-publisher.service';
|
||||||
|
|
||||||
// Interface controllers
|
// Interface controllers
|
||||||
|
|
@ -28,7 +40,7 @@ import { AuthController } from './interface/http/controllers/auth.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([User, RefreshToken]),
|
TypeOrmModule.forFeature([User, RefreshToken, SmsVerification, SmsLog]),
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret',
|
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
|
// Infrastructure -> Domain port binding
|
||||||
{ provide: USER_REPOSITORY, useClass: UserRepository },
|
{ provide: USER_REPOSITORY, useClass: UserRepository },
|
||||||
{ provide: REFRESH_TOKEN_REPOSITORY, useClass: RefreshTokenRepository },
|
{ 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
|
// Infrastructure
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
|
|
@ -49,8 +72,9 @@ import { AuthController } from './interface/http/controllers/auth.controller';
|
||||||
// Application services
|
// Application services
|
||||||
AuthService,
|
AuthService,
|
||||||
TokenService,
|
TokenService,
|
||||||
|
SmsService,
|
||||||
EventPublisherService,
|
EventPublisherService,
|
||||||
],
|
],
|
||||||
exports: [AuthService, TokenService],
|
exports: [AuthService, TokenService, SmsService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,12 @@ export class User {
|
||||||
@Column({ type: 'varchar', length: 5, nullable: true })
|
@Column({ type: 'varchar', length: 5, nullable: true })
|
||||||
nationality: string | null;
|
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 })
|
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||||
lastLoginAt: Date | null;
|
lastLoginAt: Date | null;
|
||||||
|
|
||||||
|
|
@ -87,4 +93,57 @@ export class User {
|
||||||
|
|
||||||
@VersionColumn({ default: 1 })
|
@VersionColumn({ default: 1 })
|
||||||
version: number;
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,37 @@ export interface PasswordChangedEvent {
|
||||||
userId: string;
|
userId: string;
|
||||||
timestamp: 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -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',
|
||||||
|
);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
|
import { SmsVerificationType } from '../../domain/entities/sms-verification.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SMS code storage service using Redis.
|
* SMS code Redis cache service.
|
||||||
* Stores 6-digit verification codes with a 5-minute TTL.
|
* 作为 DB 持久化的快速缓存层,用于验证码的快速查找。
|
||||||
* In dev mode, codes are logged to console instead of sent via SMS.
|
* Key pattern: auth:sms:{type}:{phone}
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsCodeService implements OnModuleInit, OnModuleDestroy {
|
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.
|
* 缓存验证码到 Redis
|
||||||
* TTL is 5 minutes (300 seconds).
|
* @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> {
|
async generateCode(phone: string): Promise<string> {
|
||||||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||||
await this.redis.set(phone, code, 'EX', 300);
|
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}`);
|
this.logger.log(`[DEV] SMS code for ${phone}: ${code}`);
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @deprecated Use verifyAndDelete() with type parameter */
|
||||||
* Verify the code for the given phone number.
|
|
||||||
* Returns true if valid, false otherwise.
|
|
||||||
* On successful verification, the code is deleted to prevent reuse.
|
|
||||||
*/
|
|
||||||
async verifyCode(phone: string, code: string): Promise<boolean> {
|
async verifyCode(phone: string, code: string): Promise<boolean> {
|
||||||
const stored = await this.redis.get(phone);
|
const stored = await this.redis.get(phone);
|
||||||
if (!stored || stored !== code) {
|
if (!stored || stored !== code) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Delete the code after successful verification
|
|
||||||
await this.redis.del(phone);
|
await this.redis.del(phone);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||||
import { AuthService } from '../../../application/services/auth.service';
|
import { AuthService } from '../../../application/services/auth.service';
|
||||||
import { RegisterDto } from '../dto/register.dto';
|
import { RegisterDto } from '../dto/register.dto';
|
||||||
import { LoginDto } from '../dto/login.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 { ChangePasswordDto } from '../dto/change-password.dto';
|
||||||
import { SendSmsCodeDto } from '../dto/send-sms-code.dto';
|
import { SendSmsCodeDto } from '../dto/send-sms-code.dto';
|
||||||
import { LoginPhoneDto } from '../dto/login-phone.dto';
|
import { LoginPhoneDto } from '../dto/login-phone.dto';
|
||||||
|
import { ResetPasswordDto } from '../dto/reset-password.dto';
|
||||||
|
import { ChangePhoneDto } from '../dto/change-phone.dto';
|
||||||
|
|
||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
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')
|
@Post('register')
|
||||||
@ApiOperation({ summary: 'Register a new user' })
|
@ApiOperation({ summary: '手机号注册 (需先获取验证码)' })
|
||||||
@ApiResponse({ status: 201, description: 'User registered successfully' })
|
@ApiResponse({ status: 201, description: '注册成功' })
|
||||||
@ApiResponse({ status: 409, description: 'Phone/email already exists' })
|
@ApiResponse({ status: 400, description: '验证码错误' })
|
||||||
|
@ApiResponse({ status: 409, description: '手机号已注册' })
|
||||||
async register(@Body() dto: RegisterDto) {
|
async register(@Body() dto: RegisterDto) {
|
||||||
const result = await this.authService.register(dto);
|
const result = await this.authService.register(dto);
|
||||||
return {
|
return {
|
||||||
code: 0,
|
code: 0,
|
||||||
data: result,
|
data: result,
|
||||||
message: 'Registration successful',
|
message: '注册成功',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 密码登录 ── */
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Login with phone/email and password' })
|
@ApiOperation({ summary: '密码登录 (手机号/邮箱 + 密码)' })
|
||||||
@ApiResponse({ status: 200, description: 'Login successful' })
|
@ApiResponse({ status: 200, description: '登录成功' })
|
||||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
@ApiResponse({ status: 401, description: '账号或密码错误' })
|
||||||
|
@ApiResponse({ status: 403, description: '账号已锁定/冻结' })
|
||||||
async login(@Body() dto: LoginDto, @Ip() ip: string) {
|
async login(@Body() dto: LoginDto, @Ip() ip: string) {
|
||||||
const result = await this.authService.login({
|
const result = await this.authService.login({
|
||||||
...dto,
|
...dto,
|
||||||
|
|
@ -49,77 +75,111 @@ export class AuthController {
|
||||||
return {
|
return {
|
||||||
code: 0,
|
code: 0,
|
||||||
data: result,
|
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')
|
@Post('refresh')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Refresh access token using refresh token' })
|
@ApiOperation({ summary: '刷新 access token' })
|
||||||
@ApiResponse({ status: 200, description: 'Token refreshed' })
|
@ApiResponse({ status: 200, description: 'Token 刷新成功' })
|
||||||
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
|
@ApiResponse({ status: 401, description: 'Refresh token 无效' })
|
||||||
async refresh(@Body() dto: RefreshTokenDto) {
|
async refresh(@Body() dto: RefreshTokenDto) {
|
||||||
const tokens = await this.authService.refreshToken(dto.refreshToken);
|
const tokens = await this.authService.refreshToken(dto.refreshToken);
|
||||||
return {
|
return {
|
||||||
code: 0,
|
code: 0,
|
||||||
data: tokens,
|
data: tokens,
|
||||||
message: 'Token refreshed',
|
message: 'Token 刷新成功',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 登出 ── */
|
||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Logout - revoke all refresh tokens' })
|
@ApiOperation({ summary: '登出 (撤销所有 refresh token)' })
|
||||||
async logout(@Req() req: any) {
|
async logout(@Req() req: any) {
|
||||||
await this.authService.logout(req.user.id);
|
await this.authService.logout(req.user.id);
|
||||||
return {
|
return {
|
||||||
code: 0,
|
code: 0,
|
||||||
data: null,
|
data: null,
|
||||||
message: 'Logged out successfully',
|
message: '已登出',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 修改密码 ── */
|
||||||
|
|
||||||
@Post('change-password')
|
@Post('change-password')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Change password' })
|
@ApiOperation({ summary: '修改密码 (需登录)' })
|
||||||
async changePassword(@Req() req: any, @Body() dto: ChangePasswordDto) {
|
async changePassword(@Req() req: any, @Body() dto: ChangePasswordDto) {
|
||||||
await this.authService.changePassword(req.user.id, dto.oldPassword, dto.newPassword);
|
await this.authService.changePassword(req.user.id, dto.oldPassword, dto.newPassword);
|
||||||
return {
|
return {
|
||||||
code: 0,
|
code: 0,
|
||||||
data: null,
|
data: null,
|
||||||
message: 'Password changed successfully',
|
message: '密码修改成功',
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@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',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
import { IsString, IsNotEmpty, Length } from 'class-validator';
|
import { IsString, IsNotEmpty, IsOptional, Length } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class LoginPhoneDto {
|
export class LoginPhoneDto {
|
||||||
@ApiProperty({ description: 'Phone number', example: '+8613800138000' })
|
@ApiProperty({ description: '手机号', example: '+8613800138000' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '6-digit SMS verification code', example: '123456' })
|
@ApiProperty({ description: '6位短信验证码', example: '123456' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(6, 6)
|
@Length(6, 6)
|
||||||
smsCode: string;
|
smsCode: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '设备信息' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
deviceInfo?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class RegisterDto {
|
export class RegisterDto {
|
||||||
@ApiPropertyOptional({ example: '+8613800138000' })
|
@ApiProperty({ description: '手机号 (E.164 或大陆格式)', example: '+8613800138000' })
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\+?[1-9]\d{6,14}$/, { message: 'Invalid phone number format' })
|
@Matches(/^\+?[1-9]\d{6,14}$/, { message: '手机号格式无效' })
|
||||||
phone?: string;
|
phone: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'user@example.com' })
|
@ApiProperty({ description: '6位短信验证码', example: '123456' })
|
||||||
@IsOptional()
|
@IsString()
|
||||||
@IsEmail()
|
@Length(6, 6, { message: '验证码必须为6位数字' })
|
||||||
email?: string;
|
smsCode: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Password123!', minLength: 8 })
|
@ApiProperty({ description: '登录密码 (8-128位)', example: 'Password123!' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8)
|
||||||
@MaxLength(128)
|
@MaxLength(128)
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'John' })
|
@ApiPropertyOptional({ description: '昵称', example: 'John' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
import { IsString, IsNotEmpty } from 'class-validator';
|
import { IsString, IsNotEmpty, IsEnum } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { SmsVerificationType } from '../../../domain/entities/sms-verification.entity';
|
||||||
|
|
||||||
export class SendSmsCodeDto {
|
export class SendSmsCodeDto {
|
||||||
@ApiProperty({ description: 'Phone number to send SMS code to', example: '+8613800138000' })
|
@ApiProperty({ description: '手机号', example: '+8613800138000' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '验证码类型',
|
||||||
|
enum: SmsVerificationType,
|
||||||
|
example: SmsVerificationType.LOGIN,
|
||||||
|
})
|
||||||
|
@IsEnum(SmsVerificationType, { message: '无效的验证码类型' })
|
||||||
|
type: SmsVerificationType;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue