feat: 后端调整登陆逻辑
This commit is contained in:
@@ -2,37 +2,86 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
NotImplementedException,
|
||||||
Post,
|
Post,
|
||||||
Request,
|
Request,
|
||||||
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginByPasswordDto } from './dto/login.dto';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { UserSessionService } from 'src/user/services/user-session.service';
|
import { UserSessionService } from 'src/user/services/user-session.service';
|
||||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { UserService } from 'src/user/user.service';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
|
private readonly userService: UserService,
|
||||||
private readonly userSessionService: UserSessionService,
|
private readonly userSessionService: UserSessionService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
@Post('login')
|
// @Post('login')
|
||||||
@UseGuards(ThrottlerGuard)
|
// @UseGuards(ThrottlerGuard)
|
||||||
@Throttle({ default: { limit: 20, ttl: 60000 } })
|
// @Throttle({ default: { limit: 20, ttl: 60000 } })
|
||||||
async login(@Body() loginDto: LoginDto) {
|
// async login(@Body() loginDto: LoginDto) {
|
||||||
switch (loginDto.type) {
|
// switch (loginDto.type) {
|
||||||
case 'password':
|
// case 'password':
|
||||||
return this.authService.loginWithPassword(loginDto);
|
// return this.authService.loginWithPassword(loginDto);
|
||||||
case 'phone':
|
// case 'phone':
|
||||||
return this.authService.loginWithPhone(loginDto);
|
// return this.authService.loginWithPhone(loginDto);
|
||||||
case 'email':
|
// case 'email':
|
||||||
return this.authService.loginWithEmail(loginDto);
|
// return this.authService.loginWithEmail(loginDto);
|
||||||
default:
|
// default:
|
||||||
throw new BadRequestException('服务器错误');
|
// throw new BadRequestException('服务器错误');
|
||||||
}
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
private setUserToken(res: Response, token: string) {
|
||||||
|
res.cookie('token', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
// 永不过期,不用设置maxAge
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login/password')
|
||||||
|
async loginByPassword(
|
||||||
|
@Body() loginDto: LoginByPasswordDto,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const { identifier, password } = loginDto;
|
||||||
|
const loginRes = await this.authService.loginWithPassword(identifier, password);
|
||||||
|
const { userId, token } = loginRes;
|
||||||
|
this.setUserToken(res, token);
|
||||||
|
return {
|
||||||
|
user: await this.userService.findById(userId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sms/send')
|
||||||
|
async sendSms() {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login/sms')
|
||||||
|
async loginBySms() {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('passkey/login/options')
|
||||||
|
async loginByPasskeyOptions() {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('passkey/login')
|
||||||
|
async loginByPasskey() {
|
||||||
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { LoginDto } from './dto/login.dto';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
import { UserService } from 'src/user/user.service';
|
||||||
import { User } from 'src/user/entities/user.entity';
|
import { User } from 'src/user/entities/user.entity';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { UserSessionService } from 'src/user/services/user-session.service';
|
import { UserSessionService } from 'src/user/services/user-session.service';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { VerificationService } from 'src/verification/verification.service';
|
import { VerificationService } from 'src/verification/verification.service';
|
||||||
|
import { BusinessException } from 'src/common/exceptions/business.exception';
|
||||||
|
import { ErrorCode } from 'src/common/constants/error-codes';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -15,40 +15,49 @@ export class AuthService {
|
|||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly userSessionService: UserSessionService,
|
private readonly userSessionService: UserSessionService,
|
||||||
private readonly verificationService: VerificationService,
|
private readonly verificationService: VerificationService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async loginWithPassword(loginDto: LoginDto) {
|
async loginWithPassword(identifier: string, password: string) {
|
||||||
const { account, password } = loginDto;
|
// 依次使用邮箱、手机号、账号登陆(防止有大聪明给账号改成别人的邮箱或手机号)
|
||||||
// 依次使用邮箱登录、手机号、账号
|
|
||||||
const user = await this.userService.findOne(
|
const user = await this.userService.findOne(
|
||||||
[{ email: account }, { phone: account }, { username: account }],
|
[{ email: identifier }, { phone: identifier }, { username: identifier }],
|
||||||
{
|
{
|
||||||
withDeleted: true,
|
withDeleted: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (user && user.deletedAt !== null) {
|
if (user && user.deletedAt !== null) {
|
||||||
throw new BadRequestException('该账号注销中');
|
throw new BusinessException({
|
||||||
|
message: '该账号注销中',
|
||||||
|
code: ErrorCode.USER_ACCOUNT_DEACTIVATED,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user === null || !user.password_hash || !user.salt) {
|
if (user === null || !user.password_hash || !user.salt) {
|
||||||
throw new BadRequestException('账户或密码错误');
|
throw new BusinessException({
|
||||||
|
message: '账户或密码错误',
|
||||||
|
code: ErrorCode.AUTH_INVALID_CREDENTIALS
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断密码是否正确
|
// 判断密码是否正确
|
||||||
const hashedPassword = this.hashPassword(password, user.salt);
|
const hashedPassword = this.hashPassword(password, user.salt);
|
||||||
if (hashedPassword !== user.password_hash) {
|
if (hashedPassword !== user.password_hash) {
|
||||||
throw new BadRequestException('账户或密码错误');
|
throw new BusinessException({
|
||||||
|
message: '账户或密码错误',
|
||||||
|
code: ErrorCode.AUTH_INVALID_CREDENTIALS
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录成功,颁发token
|
// 登录成功,颁发token
|
||||||
return {
|
return {
|
||||||
token: await this.generateToken(user),
|
token: await this.generateToken(user),
|
||||||
|
userId: user.userId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginWithPhone(loginDto: LoginDto) {
|
async loginWithPhone(data: { phone: string; code: string; }) {
|
||||||
const { phone, code } = loginDto;
|
const { phone, code } = data;
|
||||||
// 先判断验证码是否正确
|
// 先判断验证码是否正确
|
||||||
const isValid = this.verificationService.verifyPhoneCode(
|
const isValid = this.verificationService.verifyPhoneCode(
|
||||||
phone,
|
phone,
|
||||||
@@ -90,65 +99,21 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginWithEmail(loginDto: LoginDto) {
|
|
||||||
const { email, code } = loginDto;
|
|
||||||
// 先判断验证码是否正确
|
|
||||||
const isValid = this.verificationService.verifyEmailCode(
|
|
||||||
email,
|
|
||||||
code,
|
|
||||||
'login',
|
|
||||||
);
|
|
||||||
switch (isValid) {
|
|
||||||
case 0:
|
|
||||||
break;
|
|
||||||
case -1:
|
|
||||||
throw new BadRequestException('验证码已过期,请重新获取');
|
|
||||||
case -2:
|
|
||||||
throw new BadRequestException('验证码错误');
|
|
||||||
case -3:
|
|
||||||
throw new BadRequestException('验证码已失效,请重新获取');
|
|
||||||
default:
|
|
||||||
throw new BadRequestException('验证码错误,请稍后再试');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 判断用户是否存在,若不存在则进行注册
|
|
||||||
let user = await this.userService.findOne({ email }, { withDeleted: true });
|
|
||||||
if (user && user.deletedAt !== null) {
|
|
||||||
throw new BadRequestException('该账号注销中,请使用其他邮箱');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
// 执行注册操作
|
|
||||||
user = await this.userService.create({ email: email });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user || !user.userId) {
|
|
||||||
// 注册失败或用户信息错误
|
|
||||||
throw new BadRequestException('请求失败,请稍后再试');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登录,颁发token
|
|
||||||
return {
|
|
||||||
token: await this.generateToken(user),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private hashPassword(password: string, salt: string): string {
|
private hashPassword(password: string, salt: string): string {
|
||||||
return createHash('sha256').update(`${password}${salt}`).digest('hex');
|
return createHash('sha256').update(`${password}${salt}`).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateToken(user: User) {
|
private async generateToken(user: User) {
|
||||||
|
// 存储
|
||||||
|
const sessionRes = await this.userSessionService.createSession(
|
||||||
|
user.userId,
|
||||||
|
);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
sessionId: uuidv4(),
|
sessionId: sessionRes.sessionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 存储
|
|
||||||
await this.userSessionService.createSession(
|
|
||||||
payload.userId,
|
|
||||||
payload.sessionId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 颁发token
|
// 颁发token
|
||||||
return this.jwtService.sign(payload);
|
return this.jwtService.sign(payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,37 @@
|
|||||||
import { IsEnum, IsString, Length, ValidateIf } from 'class-validator';
|
import { IsEnum, IsString, Length, ValidateIf } from 'class-validator';
|
||||||
|
|
||||||
export class LoginDto {
|
// export class LoginDto {
|
||||||
@IsEnum(['password', 'phone', 'email'], { message: '请求类型错误' })
|
// @IsEnum(['password', 'phone', 'email'], { message: '请求类型错误' })
|
||||||
type: 'password' | 'phone' | 'email';
|
// type: 'password' | 'phone' | 'email';
|
||||||
|
|
||||||
@ValidateIf((o) => o.type === 'password')
|
// @ValidateIf((o) => o.type === 'password')
|
||||||
|
|
||||||
|
// account?: string;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// @ValidateIf((o) => o.type === 'phone')
|
||||||
|
// @IsString({ message: '手机号必须输入' })
|
||||||
|
// @Length(11, 11, { message: '手机号异常' }) // 中国大陆,11位数字
|
||||||
|
// phone?: string;
|
||||||
|
|
||||||
|
// @ValidateIf((o) => o.type === 'email')
|
||||||
|
// @IsString({ message: '邮箱必须输入' })
|
||||||
|
// @Length(6, 254, { message: '邮箱异常' }) // RFC 5321
|
||||||
|
// email?: string;
|
||||||
|
|
||||||
|
// @ValidateIf((o) => o.type === 'phone' || o.type === 'email')
|
||||||
|
// @IsString({ message: '验证码必须输入' })
|
||||||
|
// @Length(6, 6, { message: '验证码异常' }) // 6位数字
|
||||||
|
// code?: string;
|
||||||
|
// }
|
||||||
|
|
||||||
|
export class LoginByPasswordDto {
|
||||||
@IsString({ message: '账户必须输入' })
|
@IsString({ message: '账户必须输入' })
|
||||||
@Length(1, 254, { message: '账户异常' }) // 用户名、邮箱、手机号
|
@Length(1, 254, { message: '账户异常' }) // 用户名、邮箱、手机号
|
||||||
account?: string;
|
identifier: string;
|
||||||
|
|
||||||
@ValidateIf((o) => o.type === 'password')
|
|
||||||
@IsString({ message: '密码必须输入' })
|
@IsString({ message: '密码必须输入' })
|
||||||
@Length(6, 32, { message: '密码异常' }) // 6-32位
|
@Length(6, 32, { message: '密码异常' }) // 6-32位
|
||||||
password?: string;
|
password: string;
|
||||||
|
|
||||||
@ValidateIf((o) => o.type === 'phone')
|
|
||||||
@IsString({ message: '手机号必须输入' })
|
|
||||||
@Length(11, 11, { message: '手机号异常' }) // 中国大陆,11位数字
|
|
||||||
phone?: string;
|
|
||||||
|
|
||||||
@ValidateIf((o) => o.type === 'email')
|
|
||||||
@IsString({ message: '邮箱必须输入' })
|
|
||||||
@Length(6, 254, { message: '邮箱异常' }) // RFC 5321
|
|
||||||
email?: string;
|
|
||||||
|
|
||||||
@ValidateIf((o) => o.type === 'phone' || o.type === 'email')
|
|
||||||
@IsString({ message: '验证码必须输入' })
|
|
||||||
@Length(6, 6, { message: '验证码异常' }) // 6位数字
|
|
||||||
code?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,15 +26,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
async validate(payload: any) {
|
async validate(payload: any) {
|
||||||
const { userId, sessionId } = payload ?? {};
|
const { userId, sessionId } = payload ?? {};
|
||||||
|
|
||||||
const isValidSession = await this.userSessionService.isSessionValid(
|
await this.userSessionService.isSessionValid(
|
||||||
userId,
|
userId,
|
||||||
sessionId,
|
sessionId,
|
||||||
);
|
).catch((e) => {
|
||||||
if (!isValidSession) {
|
throw new UnauthorizedException(`${e}`);
|
||||||
throw new UnauthorizedException('登录凭证已过期,请重新登录');
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.userService.findById(userId);
|
const user = await this.userService.findOne({ userId });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException('用户不存在');
|
throw new BadRequestException('用户不存在');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export class BlogController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly blogService: BlogService,
|
private readonly blogService: BlogService,
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
getBlogs() {
|
getBlogs() {
|
||||||
@@ -100,7 +100,7 @@ export class BlogController {
|
|||||||
throw new BadRequestException('作者关闭了该文章的评论功能');
|
throw new BadRequestException('作者关闭了该文章的评论功能');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = userId ? await this.userService.findById(userId) : null;
|
const user = userId ? await this.userService.findOne({ userId }) : null;
|
||||||
|
|
||||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||||
// 获取IP归属地
|
// 获取IP归属地
|
||||||
|
|||||||
46
apps/backend/src/common/constants/error-codes.ts
Normal file
46
apps/backend/src/common/constants/error-codes.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 全局业务错误码规范:
|
||||||
|
* - 每个模块分配一个 1000 起始的段(如 USER: -1000~1999, AUTH: -2000~2999)
|
||||||
|
* - 代码结构:{ 模块名大写 }_{ 错误语义 }
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ErrorCode = {
|
||||||
|
// 通用错误(0 ~ 999)
|
||||||
|
COMMON_INTERNAL_ERROR: -1,
|
||||||
|
COMMON_INVALID_PARAM: -2,
|
||||||
|
COMMON_NOT_FOUND: -3,
|
||||||
|
|
||||||
|
// 用户模块(1000 ~ 1999)
|
||||||
|
USER_NOT_FOUND: -1001,
|
||||||
|
USER_ALREADY_EXISTS: -1002,
|
||||||
|
USER_ACCOUNT_DISABLED: -1003,
|
||||||
|
USER_FIND_OPTIONS_EMPTY: -1004,
|
||||||
|
USER_ACCOUNT_DEACTIVATED: -1005,
|
||||||
|
|
||||||
|
// 认证模块(2000 ~ 2999)
|
||||||
|
AUTH_INVALID_CREDENTIALS: -2001,
|
||||||
|
AUTH_SMS_CODE_EXPIRED: -2002,
|
||||||
|
AUTH_SMS_CODE_INCORRECT: -2003,
|
||||||
|
AUTH_PASSKEY_NOT_REGISTERED: -2004,
|
||||||
|
AUTH_SESSION_EXPIRED: -2005,
|
||||||
|
|
||||||
|
// 博客模块(3000 ~ 3999)
|
||||||
|
BLOG_NOT_FOUND: -3001,
|
||||||
|
BLOG_PERMISSION_DENIED: -3002,
|
||||||
|
|
||||||
|
// 验证模块(4000 ~ 4999)
|
||||||
|
VERIFICATION_CODE_EXPIRED: -4001,
|
||||||
|
VERIFICATION_CODE_INCORRECT: -4002,
|
||||||
|
|
||||||
|
// 通知模块(5000 ~ 5999)
|
||||||
|
NOTIFICATION_SEND_FAILED: -5001,
|
||||||
|
|
||||||
|
// 资源模块(6000 ~ 6999)
|
||||||
|
RESOURCE_UPLOAD_FAILED: -6001,
|
||||||
|
RESOURCE_NOT_FOUND: -6002,
|
||||||
|
|
||||||
|
// 管理员模块(7000 ~ 7999)
|
||||||
|
ADMIN_FORBIDDEN: -7001,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ErrorCodeType = typeof ErrorCode[keyof typeof ErrorCode];
|
||||||
@@ -8,18 +8,18 @@ import Credential, { Config } from '@alicloud/credentials';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationService {
|
export class NotificationService {
|
||||||
private dm: Dm20151123;
|
// private dm: Dm20151123;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const credentialsConfig = new Config({
|
// const credentialsConfig = new Config({
|
||||||
type: 'access_key',
|
// type: 'access_key',
|
||||||
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID,
|
// accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID,
|
||||||
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET,
|
// accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET,
|
||||||
});
|
// });
|
||||||
const credential = new Credential(credentialsConfig);
|
// const credential = new Credential(credentialsConfig);
|
||||||
const config = new $OpenApi.Config({ credential });
|
// const config = new $OpenApi.Config({ credential });
|
||||||
config.endpoint = 'dm.aliyuncs.com';
|
// config.endpoint = 'dm.aliyuncs.com';
|
||||||
this.dm = new Dm20151123(config);
|
// this.dm = new Dm20151123(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMailHtmlBody(option: { type: 'login-verify'; code: string }) {
|
private getMailHtmlBody(option: { type: 'login-verify'; code: string }) {
|
||||||
@@ -86,27 +86,28 @@ export class NotificationService {
|
|||||||
targetMail: string;
|
targetMail: string;
|
||||||
code: string;
|
code: string;
|
||||||
}) {
|
}) {
|
||||||
const runtime = new $Util.RuntimeOptions({});
|
// const runtime = new $Util.RuntimeOptions({});
|
||||||
|
|
||||||
const singleSendMailRequest = new $Dm20151123.SingleSendMailRequest({
|
// const singleSendMailRequest = new $Dm20151123.SingleSendMailRequest({
|
||||||
accountName: 'security@tonesc.cn',
|
// accountName: 'security@tonesc.cn',
|
||||||
addressType: 1,
|
// addressType: 1,
|
||||||
replyToAddress: false,
|
// replyToAddress: false,
|
||||||
toAddress: `${option.targetMail}`,
|
// toAddress: `${option.targetMail}`,
|
||||||
subject: '【特恩的日志】登陆验证码',
|
// subject: '【特恩的日志】登陆验证码',
|
||||||
htmlBody: this.getMailHtmlBody({
|
// htmlBody: this.getMailHtmlBody({
|
||||||
type: 'login-verify',
|
// type: 'login-verify',
|
||||||
code: option.code,
|
// code: option.code,
|
||||||
}),
|
// }),
|
||||||
textBody: '',
|
// textBody: '',
|
||||||
});
|
// });
|
||||||
|
|
||||||
try {
|
// try {
|
||||||
await this.dm.singleSendMailWithOptions(singleSendMailRequest, runtime);
|
// await this.dm.singleSendMailWithOptions(singleSendMailRequest, runtime);
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
console.error(error);
|
// console.error(error);
|
||||||
throw new BadRequestException('邮件发送失败');
|
// throw new BadRequestException('邮件发送失败');
|
||||||
}
|
// }
|
||||||
|
throw new Error('not implement')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,21 +11,17 @@ import {
|
|||||||
@Index(['sessionId', 'userId'])
|
@Index(['sessionId', 'userId'])
|
||||||
export class UserSession {
|
export class UserSession {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
|
||||||
|
|
||||||
@Column({ length: 36 })
|
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|
||||||
@Column({ length: 36 })
|
@Column({ length: 36 })
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
disabledReason?: string;
|
||||||
|
|
||||||
@CreateDateColumn({ precision: 3 })
|
@CreateDateColumn({ precision: 3 })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@DeleteDateColumn({ nullable: true, precision: 3 })
|
@DeleteDateColumn({ nullable: true, precision: 3 })
|
||||||
deletedAt: Date;
|
deletedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 考虑是否使用sessionId代替id,以节省存储空间
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -95,3 +95,13 @@ export class User {
|
|||||||
@Column({ type: 'jsonb', default: [] })
|
@Column({ type: 'jsonb', default: [] })
|
||||||
roles: RoleItem[];
|
roles: RoleItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UserPublicProfile {
|
||||||
|
userId: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
roles: RoleItem[];
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,39 +8,45 @@ export class UserSessionService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(UserSession)
|
@InjectRepository(UserSession)
|
||||||
private readonly userSessionRepository: Repository<UserSession>,
|
private readonly userSessionRepository: Repository<UserSession>,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async createSession(userId: string, sessionId: string): Promise<UserSession> {
|
async createSession(userId: string): Promise<UserSession> {
|
||||||
const session = this.userSessionRepository.create({
|
const session = this.userSessionRepository.create({
|
||||||
userId,
|
userId,
|
||||||
sessionId,
|
|
||||||
});
|
});
|
||||||
return await this.userSessionRepository.save(session);
|
return this.userSessionRepository.save(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
async isSessionValid(userId: string, sessionId: string): Promise<boolean> {
|
/**
|
||||||
|
* @throws string 无效原因
|
||||||
|
*/
|
||||||
|
async isSessionValid(userId: string, sessionId: string): Promise<void> {
|
||||||
const session = await this.userSessionRepository.findOne({
|
const session = await this.userSessionRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
sessionId,
|
sessionId,
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
|
withDeleted: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return !!session;
|
if (session === null) {
|
||||||
}
|
throw '登陆凭证无效';
|
||||||
|
|
||||||
async invalidateSession(userId: string, sessionId: string): Promise<void> {
|
|
||||||
const session = await this.userSessionRepository.findOne({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
sessionId,
|
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
await this.userSessionRepository.softDelete(session.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session.deletedAt !== null) {
|
||||||
|
throw session.disabledReason || '登陆凭证无效';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateSession(userId: string, sessionId: string, reason?: string): Promise<void> {
|
||||||
|
await this.userSessionRepository.update(
|
||||||
|
{ userId, sessionId, deletedAt: null },
|
||||||
|
{
|
||||||
|
deletedAt: new Date(),
|
||||||
|
disabledReason: reason || null,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { User } from './entities/user.entity';
|
import { User, UserPublicProfile } from './entities/user.entity';
|
||||||
import { QueryFailedError, Repository } from 'typeorm';
|
import { QueryFailedError, Repository } from 'typeorm';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { BusinessException } from 'src/common/exceptions/business.exception';
|
||||||
|
|
||||||
type UserFindOptions = Partial<
|
type UserFindOptions = Partial<
|
||||||
Pick<User, 'userId' | 'username' | 'phone' | 'email'>
|
Pick<User, 'userId' | 'username' | 'phone' | 'email'>
|
||||||
@@ -18,26 +20,54 @@ export class UserService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
private readonly userRepository: Repository<User>,
|
private readonly userRepository: Repository<User>,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated 尽量不使用该方法
|
|
||||||
*/
|
|
||||||
async findOne(
|
async findOne(
|
||||||
options: UserFindOptions | UserFindOptions[],
|
options: UserFindOptions | UserFindOptions[],
|
||||||
additionalOptions?: { withDeleted?: boolean },
|
additionalOptions?: { withDeleted?: boolean },
|
||||||
): Promise<User | null> {
|
): Promise<User | null> {
|
||||||
if (Object.keys(options).length === 0) {
|
if (Array.isArray(options)) {
|
||||||
throw new BadRequestException('查询条件不能为空');
|
if (options.length === 0) {
|
||||||
|
throw new BusinessException({
|
||||||
|
message: '查询条件不能为空',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await this.userRepository.find({
|
||||||
|
where: options,
|
||||||
|
withDeleted: additionalOptions?.withDeleted ?? false,
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
return users[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!options || typeof options !== 'object' || Object.keys(options).length === 0) {
|
||||||
|
throw new BusinessException({
|
||||||
|
message: '查询条件不能为空',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return this.userRepository.findOne({
|
return this.userRepository.findOne({
|
||||||
where: options,
|
where: options,
|
||||||
withDeleted: additionalOptions?.withDeleted || false,
|
withDeleted: additionalOptions?.withDeleted ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(userId: string): Promise<User | null> {
|
/**
|
||||||
|
* 仅包含用户可见字段
|
||||||
|
*/
|
||||||
|
async findById(userId: string): Promise<UserPublicProfile | null> {
|
||||||
return this.userRepository.findOne({
|
return this.userRepository.findOne({
|
||||||
|
select: {
|
||||||
|
avatar: true,
|
||||||
|
createdAt: true,
|
||||||
|
email: true,
|
||||||
|
nickname: true,
|
||||||
|
username: true,
|
||||||
|
phone: true,
|
||||||
|
roles: true,
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,24 +11,24 @@ import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
|||||||
|
|
||||||
@Controller('verification')
|
@Controller('verification')
|
||||||
export class VerificationController {
|
export class VerificationController {
|
||||||
constructor(private readonly verificationService: VerificationService) {}
|
constructor(private readonly verificationService: VerificationService) { }
|
||||||
|
|
||||||
@Post('send')
|
// @Post('send')
|
||||||
@UseGuards(ThrottlerGuard)
|
// @UseGuards(ThrottlerGuard)
|
||||||
@Throttle({ default: { limit: 20, ttl: 60000 } })
|
// @Throttle({ default: { limit: 20, ttl: 60000 } })
|
||||||
async sendVerificationCode(@Body() dto: SendVerificationCodeDto) {
|
// async sendVerificationCode(@Body() dto: SendVerificationCodeDto) {
|
||||||
switch (dto.type) {
|
// switch (dto.type) {
|
||||||
case 'login':
|
// case 'login':
|
||||||
switch (dto.targetType) {
|
// switch (dto.targetType) {
|
||||||
case 'phone':
|
// case 'phone':
|
||||||
return this.verificationService.sendPhoneCode(dto.phone, dto.type);
|
// return this.verificationService.sendPhoneCode(dto.phone, dto.type);
|
||||||
case 'email':
|
// case 'email':
|
||||||
return this.verificationService.sendEmailCode(dto.email, dto.type);
|
// return this.verificationService.sendEmailCode(dto.email, dto.type);
|
||||||
default:
|
// default:
|
||||||
throw new BadRequestException('不支持的目标类型');
|
// throw new BadRequestException('不支持的目标类型');
|
||||||
}
|
// }
|
||||||
default:
|
// default:
|
||||||
throw new BadRequestException('不支持的验证码类型');
|
// throw new BadRequestException('不支持的验证码类型');
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { NotificationService } from 'src/notification/notification.service';
|
|||||||
export class VerificationService {
|
export class VerificationService {
|
||||||
private readonly logger = new Logger(VerificationService.name);
|
private readonly logger = new Logger(VerificationService.name);
|
||||||
|
|
||||||
constructor(private readonly notificationService: NotificationService) {}
|
constructor(private readonly notificationService: NotificationService) { }
|
||||||
|
|
||||||
private pool: Map<
|
private pool: Map<
|
||||||
string,
|
string,
|
||||||
@@ -121,7 +121,10 @@ export class VerificationService {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateCode() {
|
/**
|
||||||
|
* 生成100000~999999的随机纯数字验证码
|
||||||
|
*/
|
||||||
|
private generateCode(): string {
|
||||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user