feat: 后端调整登陆逻辑

This commit is contained in:
2025-12-16 22:48:51 +08:00
parent b235ca8a6e
commit 70517058ae
13 changed files with 305 additions and 194 deletions

View File

@@ -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'))

View File

@@ -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);
} }

View File

@@ -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;
} }

View File

@@ -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('用户不存在');
} }

View File

@@ -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归属地

View 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];

View File

@@ -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')
} }
/** /**

View File

@@ -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以节省存储空间
*/

View File

@@ -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;
}

View File

@@ -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> { if (session.deletedAt !== null) {
const session = await this.userSessionRepository.findOne({ throw session.disabledReason || '登陆凭证无效';
where: {
userId,
sessionId,
deletedAt: null,
},
});
if (session) {
await this.userSessionRepository.softDelete(session.id);
} }
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,
}
)
} }
} }

View File

@@ -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({
return this.userRepository.findOne({ message: '查询条件不能为空',
where: options, });
withDeleted: additionalOptions?.withDeleted || false, }
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: '查询条件不能为空',
}); });
} }
async findById(userId: string): Promise<User | null> {
return this.userRepository.findOne({ return this.userRepository.findOne({
where: options,
withDeleted: additionalOptions?.withDeleted ?? false,
});
}
/**
* 仅包含用户可见字段
*/
async findById(userId: string): Promise<UserPublicProfile | null> {
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,
}, },

View File

@@ -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('不支持的验证码类型');
} // }
} // }
} }

View File

@@ -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() { /**
* 生成100000999999的随机纯数字验证码
*/
private generateCode(): string {
return Math.floor(100000 + Math.random() * 900000).toString(); return Math.floor(100000 + Math.random() * 900000).toString();
} }
} }