diff --git a/src/modules/user/config.ts b/src/modules/user/config.ts index 1e6fb55..d5e7b9d 100644 --- a/src/modules/user/config.ts +++ b/src/modules/user/config.ts @@ -19,6 +19,23 @@ export function defaultUserConfig(configure: Configure): UserConfig { 3600 * 30, ), }, + captcha: { + sms: { + login: { + template: configure.env.get('SMS_LOGIN_CAPTCHA_CLOUD', 'your-id'), + }, + register: { + template: configure.env.get('SMS_REGISTER_CAPTCHA_CLOUD', 'your-id'), + }, + 'retrieve-password': { + template: configure.env.get('SMS_RETRIEVE_PASSWORD_CAPTCHA_CLOUD', 'your-id'), + }, + }, + email: { + register: {}, + 'retrieve-password': {}, + }, + }, }; } diff --git a/src/modules/user/constants.ts b/src/modules/user/constants.ts index 5cad298..1530920 100644 --- a/src/modules/user/constants.ts +++ b/src/modules/user/constants.ts @@ -40,3 +40,68 @@ export const TokenConst = { }; export const ALLOW_GUEST = 'allowGuest'; + +/** + * 验证码发送数据DTO验证组 + */ +export enum CaptchaDtoGroups { + // 发送短信登录验证码 + PHONE_LOGIN = 'phone-login', + // 发送邮件登录验证码 + EMAIL_LOGIN = 'email-login', + // 发送短信注册验证码 + PHONE_REGISTER = 'phone-register', + // 发送邮件注册验证码 + EMAIL_REGISTER = 'email-register', + // 发送找回密码的短信验证码 + PHONE_RETRIEVE_PASSWORD = 'phone-retrieve-password', + // 发送找回密码的邮件验证码 + EMAIL_RETRIEVE_PASSWORD = 'email-retrieve-password', + // 发送登录用户密码重置的短信验证码 + PHONE_RESET_PASSWORD = 'phone-reset-password', + // 发送登录用户密码重置的邮件验证码 + EMAIL_RESET_PASSWORD = 'email-reset-password', + // 发送手机号绑定或更改的短信验证码 + BOUND_PHONE = 'bound-phone', + // 发送邮箱地址绑定或更改的邮件验证码 + BOUND_EMAIL = 'bound-email', +} + +/** + * 验证码操作类别 + */ +export enum CaptchaActionType { + // 登录操作 + LOGIN = 'login', + // 注册操作 + REGISTER = 'register', + // 找回密码操作 + RETRIEVE_PASSWORD = 'retrieve-password', + // 重置密码操作 + RESET_PASSWORD = 'reset-password', + // 手机号或邮箱地址绑定操作 + ACCOUNT_BOUND = 'account-bound', +} + +/** + * 验证码类型 + */ +export enum CaptchaType { + SMS = 'sms', + EMAIL = 'email', +} + +/** + * 发送验证码异步列队名称 + */ +export const SEND_CAPTCHA_QUEUE = 'send-captcha-queue'; + +/** + * 发送短信验证码任务处理名称 + */ +export const SMS_CAPTCHA_JOB = 'sms-captcha-job'; + +/** + * 发送邮件验证码任务处理名称 + */ +export const EMAIL_CAPTCHA_JOB = 'mail-captcha-job'; diff --git a/src/modules/user/dtos/account.dto.ts b/src/modules/user/dtos/account.dto.ts index b425ed8..56a8e0b 100644 --- a/src/modules/user/dtos/account.dto.ts +++ b/src/modules/user/dtos/account.dto.ts @@ -1,12 +1,12 @@ -import { PickType } from '@nestjs/swagger'; +import { OmitType, PickType } from '@nestjs/swagger'; -import { IsDefined, IsUUID, Length } from 'class-validator'; +import { Length } from 'class-validator'; import { IsPassword } from '@/modules/core/constraints/password.constraint'; import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator'; import { UserCommonDto } from '@/modules/user/dtos/user.common.dto'; -import { UserValidateGroup } from '../constants'; +import { CaptchaDtoGroups, UserValidateGroup } from '../constants'; /** * 更新用户信息 @@ -19,13 +19,6 @@ export class UpdateAccountDto extends PickType(UserCommonDto, ['username', 'nick */ @DtoValidation({ groups: [UserValidateGroup.CHANGE_PASSWORD] }) export class UpdatePasswordDto extends PickType(UserCommonDto, ['password', 'plainPassword']) { - /** - * 待更新的用户ID - */ - @IsUUID(undefined, { message: '用户ID格式不正确', groups: [UserValidateGroup.USER_UPDATE] }) - @IsDefined({ groups: ['update'], message: '用户ID必须指定' }) - id: string; - /** * 旧密码:用户在更改密码时需要输入的原密码 */ @@ -33,3 +26,20 @@ export class UpdatePasswordDto extends PickType(UserCommonDto, ['password', 'pla @Length(8, 50, { message: '密码长度不得少于$constraint1', always: true }) oldPassword: string; } + +/** + * 对手机/邮箱绑定验证码进行验证 + */ +export class AccountBoundDto extends PickType(UserCommonDto, ['code', 'phone', 'email']) {} + +/** + * 绑定或更改手机号验证 + */ +@DtoValidation({ groups: [CaptchaDtoGroups.BOUND_PHONE] }) +export class PhoneBoundDto extends OmitType(AccountBoundDto, ['email'] as const) {} + +/** + * 绑定或更改邮箱验证 + */ +@DtoValidation({ groups: [CaptchaDtoGroups.BOUND_EMAIL] }) +export class EmailBoundDto extends OmitType(AccountBoundDto, ['phone'] as const) {} diff --git a/src/modules/user/dtos/auth.dto.ts b/src/modules/user/dtos/auth.dto.ts index 40ed663..0f03c86 100644 --- a/src/modules/user/dtos/auth.dto.ts +++ b/src/modules/user/dtos/auth.dto.ts @@ -1,7 +1,7 @@ import { PickType } from '@nestjs/swagger'; import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator'; -import { UserValidateGroup } from '@/modules/user/constants'; +import { CaptchaDtoGroups, UserValidateGroup } from '@/modules/user/constants'; import { UserCommonDto } from '@/modules/user/dtos/user.common.dto'; /** @@ -9,6 +9,18 @@ import { UserCommonDto } from '@/modules/user/dtos/user.common.dto'; */ export class CredentialDto extends PickType(UserCommonDto, ['credential', 'password']) {} +/** + * 通过手机验证码登录 + */ +@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_LOGIN] }) +export class PhoneLoginDto extends PickType(UserCommonDto, ['phone', 'code'] as const) {} + +/** + * 通过邮箱验证码登录 + */ +@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_LOGIN] }) +export class EmailLoginDto extends PickType(UserCommonDto, ['email', 'code'] as const) {} + /** * 普通方式注册用户 */ @@ -19,3 +31,47 @@ export class RegisterDto extends PickType(UserCommonDto, [ 'password', 'plainPassword', ] as const) {} + +/** + * 通过手机验证码注册 + */ +@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_REGISTER] }) +export class PhoneRegisterDto extends PickType(UserCommonDto, ['phone', 'code'] as const) {} + +/** + * 通过邮件验证码注册 + */ +@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_REGISTER] }) +export class EmailRegisterDto extends PickType(UserCommonDto, ['email', 'code'] as const) {} + +/** + * 通过登录凭证找回密码 + */ +export class RetrievePasswordDto extends PickType(UserCommonDto, [ + 'credential', + 'code', + 'password', + 'plainPassword', +] as const) {} + +/** + * 通过手机号找回密码 + */ +@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_RETRIEVE_PASSWORD] }) +export class PhoneRetrievePasswordDto extends PickType(UserCommonDto, [ + 'phone', + 'code', + 'password', + 'plainPassword', +] as const) {} + +/** + * 通过邮箱地址找回密码 + */ +@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_RETRIEVE_PASSWORD] }) +export class EmailRetrievePasswordDto extends PickType(UserCommonDto, [ + 'email', + 'code', + 'password', + 'plainPassword', +] as const) {} diff --git a/src/modules/user/dtos/user.common.dto.ts b/src/modules/user/dtos/user.common.dto.ts index 689ea3d..4d1bc82 100644 --- a/src/modules/user/dtos/user.common.dto.ts +++ b/src/modules/user/dtos/user.common.dto.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { IsEmail, IsNotEmpty, IsOptional, Length } from 'class-validator'; +import { IsEmail, IsEnum, IsNotEmpty, IsNumberString, IsOptional, Length } from 'class-validator'; import { IsMatch } from '@/modules/core/constraints/match.constraint'; import { IsPassword } from '@/modules/core/constraints/password.constraint'; import { IsMatchPhone } from '@/modules/core/constraints/phone.number.constraint'; import { IsUnique, IsUniqueExist } from '@/modules/database/constraints'; -import { UserValidateGroup } from '@/modules/user/constants'; +import { CaptchaType, UserValidateGroup } from '@/modules/user/constants'; import { UserEntity } from '@/modules/user/entities/user.entity'; /** @@ -111,4 +111,11 @@ export class UserCommonDto { @IsMatch('password', false, { message: '两次输入密码不同', always: true }) @IsNotEmpty({ message: '请再次输入密码以确认', always: true }) plainPassword: string; + + @IsNumberString(undefined, { message: '验证码必须为数字', always: true }) + @Length(6, 6, { message: '验证码长度错误', always: true }) + code!: string; + + @IsEnum(CaptchaType) + type: CaptchaType; } diff --git a/src/modules/user/dtos/user.dto.ts b/src/modules/user/dtos/user.dto.ts index ae07035..8d98040 100644 --- a/src/modules/user/dtos/user.dto.ts +++ b/src/modules/user/dtos/user.dto.ts @@ -1,8 +1,10 @@ import { OmitType, PartialType, PickType } from '@nestjs/swagger'; -import { IsDefined, IsEnum, IsOptional, IsUUID } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsDefined, IsEnum, IsOptional, IsUUID } from 'class-validator'; import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator'; +import { toBoolean } from '@/modules/core/helpers'; import { IsDataExist } from '@/modules/database/constraints'; import { PermissionEntity, RoleEntity } from '@/modules/rbac/entities'; import { PaginateWithTrashedDto } from '@/modules/restful/dtos/paginate-width-trashed.dto'; @@ -69,6 +71,7 @@ export class UpdateUserDto extends PartialType(CreateUserDto) { /** * 查询用户列表的Query数据验证 */ +@DtoValidation({ type: 'query', skipMissingProperties: true }) export class QueryUserDto extends PaginateWithTrashedDto { /** * 角色ID:根据角色来过滤用户 @@ -96,6 +99,13 @@ export class QueryUserDto extends PaginateWithTrashedDto { @IsEnum(UserOrderType) @IsOptional() orderBy?: UserOrderType; + + /** + * 过滤激活状态 + */ + @Transform(({ value }) => toBoolean(value)) + @IsBoolean() + actived?: boolean; } /** diff --git a/src/modules/user/entities/captcha.entity.ts b/src/modules/user/entities/captcha.entity.ts new file mode 100644 index 0000000..bdc7f60 --- /dev/null +++ b/src/modules/user/entities/captcha.entity.ts @@ -0,0 +1,45 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { CaptchaActionType, CaptchaType } from '@/modules/user/constants'; + +@Entity('user_captcha') +export class CaptchaEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ comment: '验证码' }) + code!: string; + + @Column({ + type: 'enum', + enum: CaptchaActionType, + comment: '验证操作类型', + }) + action!: CaptchaActionType; + + @Column({ + type: 'enum', + enum: CaptchaType, + comment: '验证码类型', + }) + type!: CaptchaType; + + @Column({ comment: '手机号/邮箱地址' }) + value!: string; + + @CreateDateColumn({ + comment: '创建时间', + }) + createdAt!: Date; + + @UpdateDateColumn({ + comment: '更新时间', + }) + updatedAt!: Date; +} diff --git a/src/modules/user/entities/user.entity.ts b/src/modules/user/entities/user.entity.ts index 1609a93..4a0a305 100644 --- a/src/modules/user/entities/user.entity.ts +++ b/src/modules/user/entities/user.entity.ts @@ -63,6 +63,9 @@ export class UserEntity { @Column({ comment: '用户邮箱', length: 256, nullable: true, unique: true }) email?: string; + @Column({ comment: '用户状态,是否激活', default: false }) + actived?: boolean; + /** * 用户创建时间 */ diff --git a/src/modules/user/types.ts b/src/modules/user/types.ts index cfa2e5a..dc749d9 100644 --- a/src/modules/user/types.ts +++ b/src/modules/user/types.ts @@ -1,5 +1,8 @@ +import { CaptchaActionType, CaptchaType } from '@/modules/user/constants'; +import { CaptchaEntity } from '@/modules/user/entities/captcha.entity'; + /** - * 用户配置类型 + * 自定义用户模块配置 */ export interface UserConfig { /** @@ -10,6 +13,8 @@ export interface UserConfig { * jwt token的生成配置 */ jwt: JwtConfig; + + captcha?: CustomCaptchaConfig; } /** @@ -39,3 +44,76 @@ export interface JwtPayload { */ iat: number; } + +/** + * 默认用户模块配置 + */ +export interface DefaultUserConfig { + hash: number; + jwt: Pick, 'tokenExpired' | 'refreshTokenExpired'>; + captcha: DefaultCaptchaConfig; +} + +/** + * 自定义验证码配置 + */ +export interface CustomCaptchaConfig { + [CaptchaType.SMS]?: { + [key in CaptchaActionType]?: Partial; + }; + [CaptchaType.EMAIL]?: { + [key in CaptchaActionType]?: Partial; + }; +} + +/** + * 默认验证码配置 + */ +export interface DefaultCaptchaConfig { + [CaptchaType.SMS]: { + [key in CaptchaActionType]: CaptchaOption; + }; + [CaptchaType.EMAIL]: { + [key in CaptchaActionType]: Omit; + }; +} + +/** + * 通用验证码选项 + */ +export interface CaptchaOption { + limit: number; // 验证码发送间隔时间 + expired: number; // 验证码有效时间 +} + +/** + * 手机验证码选项 + */ +export interface SmsCaptchaOption extends CaptchaOption { + template: string; // 云厂商短信推送模板ID +} + +/** + * 邮件验证码选项 + */ +export interface EmailCaptchaOption extends CaptchaOption { + subject: string; // 邮件主题 + template?: string; // 模板路径 +} + +/** + * 任务传给消费者的数据类型 + */ +export interface SendCaptchaQueueJob { + captcha: { [key in keyof CaptchaEntity]: CaptchaEntity[key] }; + option: SmsCaptchaOption | EmailCaptchaOption; + otherVars?: RecordAny; +} + +/** + * 验证码正确性验证 + */ +export type CaptchaValidate = T & { + value: string; + code: string; +}; diff --git a/src/modules/user/utils.ts b/src/modules/user/utils.ts index e46b35f..0dade30 100644 --- a/src/modules/user/utils.ts +++ b/src/modules/user/utils.ts @@ -21,3 +21,10 @@ export async function encrypt(configure: Configure, password: string) { export function decrypt(password: string, hashed: string) { return bcrypt.compareSync(password, hashed); } + +/** + * 生成随机验证码 + */ +export function generateCaptchaCode() { + return Math.random().toFixed(6).slice(-6); +}