From b8cc65a7682cc4325fa26a2a5fc9f0c04af389f4 Mon Sep 17 00:00:00 2001 From: liuyi Date: Sun, 22 Jun 2025 17:12:45 +0800 Subject: [PATCH] add user module and jwt --- src/modules/content/dtos/delete.dto.ts | 2 +- .../content/entities/category.entity.ts | 3 +- src/modules/content/entities/tag.entity.ts | 2 +- .../repositories/comment.repository.ts | 1 + .../content/services/SanitizeService.ts | 1 + .../core/constraints/match.constraint.ts | 1 + .../core/constraints/password.constraint.ts | 7 +- .../constraints/data.exist.constraint.ts | 7 +- .../constraints/tree.unique.constraint.ts | 8 +- .../database/constraints/unique.constraint.ts | 3 +- .../constraints/unique.exist.constraint.ts | 2 + src/modules/database/database.module.ts | 3 +- src/modules/user/services/auth.service.ts | 115 ++++++++++++++++++ src/modules/user/services/token.service.ts | 28 ++++- src/modules/user/services/user.service.ts | 94 ++++++++++++++ typings/global.d.ts | 16 +-- 16 files changed, 266 insertions(+), 27 deletions(-) create mode 100644 src/modules/user/services/auth.service.ts create mode 100644 src/modules/user/services/user.service.ts diff --git a/src/modules/content/dtos/delete.dto.ts b/src/modules/content/dtos/delete.dto.ts index faf6583..efce31e 100644 --- a/src/modules/content/dtos/delete.dto.ts +++ b/src/modules/content/dtos/delete.dto.ts @@ -1,4 +1,4 @@ -import { IsUUID, IsDefined } from 'class-validator'; +import { IsDefined, IsUUID } from 'class-validator'; import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator'; diff --git a/src/modules/content/entities/category.entity.ts b/src/modules/content/entities/category.entity.ts index e42450d..e2cd526 100644 --- a/src/modules/content/entities/category.entity.ts +++ b/src/modules/content/entities/category.entity.ts @@ -1,4 +1,5 @@ import { Exclude, Expose, Type } from 'class-transformer'; +import type { Relation } from 'typeorm'; import { BaseEntity, Column, @@ -10,8 +11,6 @@ import { TreeParent, } from 'typeorm'; -import type { Relation } from 'typeorm'; - import { PostEntity } from '@/modules/content/entities/post.entity'; @Exclude() diff --git a/src/modules/content/entities/tag.entity.ts b/src/modules/content/entities/tag.entity.ts index 6189fc0..710582f 100644 --- a/src/modules/content/entities/tag.entity.ts +++ b/src/modules/content/entities/tag.entity.ts @@ -1,6 +1,6 @@ import { Exclude, Expose } from 'class-transformer'; -import { Column, Entity, ManyToMany, PrimaryColumn } from 'typeorm'; import type { Relation } from 'typeorm'; +import { Column, Entity, ManyToMany, PrimaryColumn } from 'typeorm'; import { PostEntity } from '@/modules/content/entities/post.entity'; diff --git a/src/modules/content/repositories/comment.repository.ts b/src/modules/content/repositories/comment.repository.ts index de1ce6a..4bc0022 100644 --- a/src/modules/content/repositories/comment.repository.ts +++ b/src/modules/content/repositories/comment.repository.ts @@ -8,6 +8,7 @@ import { QueryHook } from '@/modules/database/types'; type FindCommentTreeOptions = FindTreeOptions & { addQuery?: QueryHook; }; + @CustomRepository(CommentEntity) export class CommentRepository extends BaseTreeRepository { protected _qbName = 'comment'; diff --git a/src/modules/content/services/SanitizeService.ts b/src/modules/content/services/SanitizeService.ts index 0c794e2..0adee14 100644 --- a/src/modules/content/services/SanitizeService.ts +++ b/src/modules/content/services/SanitizeService.ts @@ -4,6 +4,7 @@ import { deepMerge } from '@/modules/core/helpers'; export class SanitizeService { protected config: sanitizeHtml.IOptions = {}; + constructor() { this.config = { allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'code']), diff --git a/src/modules/core/constraints/match.constraint.ts b/src/modules/core/constraints/match.constraint.ts index 3f98927..cf7de6f 100644 --- a/src/modules/core/constraints/match.constraint.ts +++ b/src/modules/core/constraints/match.constraint.ts @@ -13,6 +13,7 @@ export class MatchConstraint implements ValidatorConstraintInterface { const relatedValue = (validationArguments.object as any)[relatedProperty]; return (value === relatedValue) !== reverse; } + defaultMessage?(validationArguments?: ValidationArguments): string { const [relatedProperty, reverse] = validationArguments.constraints; return `${relatedProperty} and ${validationArguments.property} ${ diff --git a/src/modules/core/constraints/password.constraint.ts b/src/modules/core/constraints/password.constraint.ts index b7b4ce7..d263055 100644 --- a/src/modules/core/constraints/password.constraint.ts +++ b/src/modules/core/constraints/password.constraint.ts @@ -1,8 +1,8 @@ import { - ValidationArguments, - ValidatorConstraintInterface, - ValidationOptions, registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraintInterface, } from 'class-validator'; type ModelType = 1 | 2 | 3 | 4 | 5; @@ -30,6 +30,7 @@ export class PasswordConstraint implements ValidatorConstraintInterface { return /\d/.test(value) && /[A-Za-z]/.test(value); } } + defaultMessage?(validationArguments?: ValidationArguments): string { return "($value) 's format error"; } diff --git a/src/modules/database/constraints/data.exist.constraint.ts b/src/modules/database/constraints/data.exist.constraint.ts index e036757..18069e1 100644 --- a/src/modules/database/constraints/data.exist.constraint.ts +++ b/src/modules/database/constraints/data.exist.constraint.ts @@ -1,13 +1,13 @@ import { Injectable } from '@nestjs/common'; import { + registerDecorator, ValidationArguments, + ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface, - ValidationOptions, - registerDecorator, } from 'class-validator'; import { isArray, isNil } from 'lodash'; -import { ObjectType, Repository, DataSource } from 'typeorm'; +import { DataSource, ObjectType, Repository } from 'typeorm'; type Condition = { entity: ObjectType; @@ -52,6 +52,7 @@ export class DataExistConstraint implements ValidatorConstraintInterface { const item = await repo.findOne({ where: { [map]: value } }); return !!item; } + defaultMessage?(validationArguments?: ValidationArguments): string { if (!validationArguments.constraints[0]) { return 'Model not been specified!'; diff --git a/src/modules/database/constraints/tree.unique.constraint.ts b/src/modules/database/constraints/tree.unique.constraint.ts index b4c5911..4d7dcd3 100644 --- a/src/modules/database/constraints/tree.unique.constraint.ts +++ b/src/modules/database/constraints/tree.unique.constraint.ts @@ -1,12 +1,12 @@ import { Injectable } from '@nestjs/common'; import { + registerDecorator, + ValidationArguments, + ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface, - ValidationArguments, - registerDecorator, - ValidationOptions, } from 'class-validator'; -import { merge, isNil } from 'lodash'; +import { isNil, merge } from 'lodash'; import { DataSource, ObjectType } from 'typeorm'; type Condition = { diff --git a/src/modules/database/constraints/unique.constraint.ts b/src/modules/database/constraints/unique.constraint.ts index e61d788..34eb3f7 100644 --- a/src/modules/database/constraints/unique.constraint.ts +++ b/src/modules/database/constraints/unique.constraint.ts @@ -2,9 +2,9 @@ import { Injectable } from '@nestjs/common'; import { registerDecorator, ValidationArguments, + ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface, - ValidationOptions, } from 'class-validator'; import { isNil, merge } from 'lodash'; import { DataSource, ObjectType } from 'typeorm'; @@ -39,6 +39,7 @@ export class UniqueConstraint implements ValidatorConstraintInterface { return false; } } + defaultMessage?(validationArguments?: ValidationArguments): string { const { entity, property } = validationArguments.constraints[0]; const queryProperty = property ?? validationArguments.property; diff --git a/src/modules/database/constraints/unique.exist.constraint.ts b/src/modules/database/constraints/unique.exist.constraint.ts index de98fd9..1ec7f55 100644 --- a/src/modules/database/constraints/unique.exist.constraint.ts +++ b/src/modules/database/constraints/unique.exist.constraint.ts @@ -18,6 +18,7 @@ type Condition = { property?: string; }; + @Injectable() @ValidatorConstraint({ name: 'dataUniqueExist', async: true }) export class UniqueExistConstraint implements ValidatorConstraintInterface { @@ -48,6 +49,7 @@ export class UniqueExistConstraint implements ValidatorConstraintInterface { }), ); } + defaultMessage?(args?: ValidationArguments): string { const { entity, property } = args.constraints[0]; const queryProperty = property ?? args.property; diff --git a/src/modules/database/database.module.ts b/src/modules/database/database.module.ts index b88b3b4..ddee7fa 100644 --- a/src/modules/database/database.module.ts +++ b/src/modules/database/database.module.ts @@ -24,7 +24,7 @@ import { DBOptions } from './types'; export class DatabaseModule { static async forRoot(configure: Configure): Promise { if (!configure.has('database')) { - panic({ message: 'Database config not exists' }); + await panic({ message: 'Database config not exists' }); } const { connections } = await configure.get('database'); const imports: ModuleMetadata['imports'] = []; @@ -46,6 +46,7 @@ export class DatabaseModule { providers, }; } + static forRepository>( repositories: T[], datasourceName?: string, diff --git a/src/modules/user/services/auth.service.ts b/src/modules/user/services/auth.service.ts new file mode 100644 index 0000000..9bb3ae5 --- /dev/null +++ b/src/modules/user/services/auth.service.ts @@ -0,0 +1,115 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { ForbiddenException, Injectable } from '@nestjs/common'; + +import { FastifyRequest as Request } from 'fastify'; + +import { ExtractJwt } from 'passport-jwt'; + +import { Configure } from '@/modules/config/configure'; + +import { getTime } from '@/modules/core/helpers/time'; +import { RegisterDto } from '@/modules/user/dtos/auth.dto'; +import { UserEntity } from '@/modules/user/entities/UserEntity'; +import { TokenService } from '@/modules/user/services/token.service'; +import { decrypt } from '@/modules/user/utils'; + +import { UpdatePasswordDto } from '../dtos/account.dto'; +import { UserRepository } from '../repositories/UserRepository'; + +import { UserService } from './user.service'; + +@Injectable() +export class AuthService { + constructor( + protected configure: Configure, + protected userService: UserService, + protected tokenService: TokenService, + protected userRepository: UserRepository, + ) {} + + /** + * 用户登录验证 + * @param credential + * @param password + */ + async validateUser(credential: string, password: string) { + const user = await this.userService.findOneByCredential(credential, async (query) => + query.addSelect('user.password'), + ); + if (user && decrypt(password, user.password)) { + return user; + } + return null; + } + + /** + * 登录用户,并生成新的accessToken和refreshToken + * @param user + */ + async login(user: UserEntity) { + const now = await getTime(this.configure); + const { accessToken } = await this.tokenService.generateAccessToken(user, now); + return accessToken.value; + } + + /** + * 注销登录 + * @param req + */ + async logout(req: Request) { + const accessToken = ExtractJwt.fromAuthHeaderAsBearerToken()(req as any); + if (accessToken) { + await this.tokenService.removeAccessToken(accessToken); + } + + return { msg: 'logout_success' }; + } + + /** + * 登录用户后生成新的token和refreshToken + * @param id + */ + async createToken(id: string) { + const now = await getTime(this.configure); + let user: UserEntity; + try { + user = await this.userService.detail(id); + } catch (err) { + throw new ForbiddenException(err); + } + const { accessToken } = await this.tokenService.generateAccessToken(user, now); + return accessToken.value; + } + + /** + * 使用用户名密码注册用户 + * @param data + */ + async register(data: RegisterDto) { + const { username, nickname, password } = data; + const user = await this.userService.create({ + username, + nickname, + password, + } as any); + return this.userService.findOneByCondition({ id: user.id }); + } + + /** + * 更新用户密码 + * @param user + * @param password + * @param oldPassword + */ + async changePassword(user: UserEntity, { password, oldPassword }: UpdatePasswordDto) { + const item = await this.userRepository.findOneOrFail({ + select: ['password'], + where: { id: user.id }, + }); + if (decrypt(oldPassword, item.password)) { + await this.userRepository.save({ id: user.id, password }, { reload: true }); + return this.userService.detail(user.id); + } + throw new ForbiddenException('old password do not match'); + } +} diff --git a/src/modules/user/services/token.service.ts b/src/modules/user/services/token.service.ts index 0a06cd0..4f3f110 100644 --- a/src/modules/user/services/token.service.ts +++ b/src/modules/user/services/token.service.ts @@ -1,7 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies */ import { Injectable } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; +import { JwtModule, JwtModuleOptions, JwtService } from '@nestjs/jwt'; import dayjs from 'dayjs'; import { FastifyReply as Response } from 'fastify'; @@ -10,11 +10,12 @@ import { v4 as uuid } from 'uuid'; import { Configure } from '@/modules/config/configure'; import { getTime } from '@/modules/core/helpers/time'; -import { getUserConfig } from '@/modules/user/config'; +import { defaultUserConfig, getUserConfig } from '@/modules/user/config'; import { UserEntity } from '@/modules/user/entities/UserEntity'; import { AccessTokenEntity } from '@/modules/user/entities/access.token.entity'; import { RefreshTokenEntity } from '@/modules/user/entities/refresh.token.entity'; -import { JwtConfig, JwtPayload } from '@/modules/user/types'; +import { JwtConfig, JwtPayload, UserConfig } from '@/modules/user/types'; + /** * 令牌服务 */ @@ -142,4 +143,25 @@ export class TokenService { } return null; } + + static JwtModuleFactory(configure: Configure) { + return JwtModule.registerAsync({ + useFactory: async (): Promise => { + const config = await configure.get( + 'user', + defaultUserConfig(configure), + ); + const options: JwtModuleOptions = { + secret: configure.env.get('USER_TOKEN_SECRET', 'my-access-secret'), + verifyOptions: { + ignoreExpiration: !configure.env.isProd(), + }, + }; + if (configure.env.isProd()) { + options.signOptions = { expiresIn: `${config.jwt.tokenExpired}s` }; + } + return options; + }, + }); + } } diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts new file mode 100644 index 0000000..99a44fa --- /dev/null +++ b/src/modules/user/services/user.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@nestjs/common'; + +import { isNil } from 'lodash'; +import { DataSource, EntityNotFoundError, SelectQueryBuilder } from 'typeorm'; + +import { Configure } from '@/modules/config/configure'; +import { BaseService } from '@/modules/database/base/service'; + +import { QueryHook } from '@/modules/database/types'; + +import { CreateUserDto, QueryUserDto, UpdateUserDto } from '../dtos/user.dto'; +import { UserEntity } from '../entities/UserEntity'; +import { UserRepository } from '../repositories/UserRepository'; + +@Injectable() +export class UserService extends BaseService { + protected enableTrash = true; + + constructor( + protected configure: Configure, + protected dataSource: DataSource, + protected userRepository: UserRepository, + ) { + super(userRepository); + } + + /** + * 创建用户 + * @param data + */ + async create(data: CreateUserDto): Promise { + const user = await this.userRepository.save(data, { reload: true }); + return this.detail(user.id); + } + + /** + * 更新用户 + * @param data + */ + async update(data: UpdateUserDto): Promise { + const updated = await this.userRepository.save(data, { reload: true }); + return this.detail(updated.id); + } + + /** + * 根据用户用户凭证查询用户 + * @param credential + * @param callback + */ + async findOneByCredential(credential: string, callback?: QueryHook) { + let query = this.userRepository.buildBaseQuery(); + if (callback) { + query = await callback(query); + } + return query + .where('user.username = :credential', { credential }) + .orWhere('user.email = :credential', { credential }) + .orWhere('user.phone = :credential', { credential }) + .getOne(); + } + + /** + * 根据对象条件查找用户,不存在则抛出异常 + * @param condition + * @param callback + */ + async findOneByCondition(condition: { [key: string]: any }, callback?: QueryHook) { + let query = this.userRepository.buildBaseQuery(); + if (callback) { + query = await callback(query); + } + const wheres = Object.fromEntries( + Object.entries(condition).map(([key, value]) => [key, value]), + ); + const user = query.where(wheres).getOne(); + if (!user) { + throw new EntityNotFoundError(UserEntity, Object.keys(condition).join(',')); + } + return user; + } + + protected async buildListQB( + queryBuilder: SelectQueryBuilder, + options: QueryUserDto, + callback?: QueryHook, + ) { + const { orderBy } = options; + const qb = await super.buildListQB(queryBuilder, options, callback); + if (!isNil(orderBy)) { + qb.orderBy(`${this.repository.qbName}.${orderBy}`, 'ASC'); + } + return qb; + } +} diff --git a/typings/global.d.ts b/typings/global.d.ts index 714339f..75b4883 100644 --- a/typings/global.d.ts +++ b/typings/global.d.ts @@ -15,20 +15,20 @@ declare type RePartial = { [P in keyof T]?: T[P] extends (infer U)[] | undefined ? RePartial[] : T[P] extends object | undefined - ? T[P] extends ((...args: any[]) => any) | ClassType | undefined - ? T[P] - : RePartial - : T[P]; + ? T[P] extends ((...args: any[]) => any) | ClassType | undefined + ? T[P] + : RePartial + : T[P]; }; declare type ReRequired = { [P in keyof T]-?: T[P] extends (infer U)[] | undefined ? ReRequired[] : T[P] extends object | undefined - ? T[P] extends ((...args: any[]) => any) | ClassType | undefined - ? T[P] - : ReRequired - : T[P]; + ? T[P] extends ((...args: any[]) => any) | ClassType | undefined + ? T[P] + : ReRequired + : T[P]; }; declare type WrapperType = T;