feat(auth): PATCH /auth/me/password 修改本人密码

- 校验原密码 + 更新后 tokenVersion 吊销其它会话

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 16:34:51 +08:00
parent 13510ee216
commit 6578db51c7
3 changed files with 42 additions and 3 deletions

View File

@ -1,7 +1,7 @@
import { Body, Controller, Get, Post, Req, UnauthorizedException } from '@nestjs/common'; import { Body, Controller, Get, Patch, Post, Req, UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express'; import type { Request } from 'express';
import { AuthService } from './auth.service.js'; import { AuthService } from './auth.service.js';
import { LoginDto, RefreshDto, RegisterDto } from './dto.js'; import { ChangePasswordDto, LoginDto, RefreshDto, RegisterDto } from './dto.js';
import { Public } from './decorators.js'; import { Public } from './decorators.js';
import { getRequestContext } from '../common/context/request-context.js'; import { getRequestContext } from '../common/context/request-context.js';
@ -48,4 +48,12 @@ export class AuthController {
if (!ctx) throw new UnauthorizedException('未登录'); if (!ctx) throw new UnauthorizedException('未登录');
return this.auth.getPermissionSet(ctx.tenantCode, ctx.userId); return this.auth.getPermissionSet(ctx.tenantCode, ctx.userId);
} }
/** 修改本人密码(仅需登录)。 */
@Patch('me/password')
changePassword(@Body() dto: ChangePasswordDto) {
const ctx = getRequestContext();
if (!ctx) throw new UnauthorizedException('未登录');
return this.auth.changePassword(ctx.tenantCode, ctx.userId, dto);
}
} }

View File

@ -4,7 +4,7 @@ import bcrypt from 'bcryptjs';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service.js'; import { PrismaService } from '../prisma/prisma.service.js';
import { RedisService } from '../common/redis/redis.service.js'; import { RedisService } from '../common/redis/redis.service.js';
import { LoginDto, RefreshDto, RegisterDto } from './dto.js'; import { LoginDto, RefreshDto, RegisterDto, ChangePasswordDto } from './dto.js';
import { resolveDataScope, resolveVisibleOrgs } from '../common/data-scope.js'; import { resolveDataScope, resolveVisibleOrgs } from '../common/data-scope.js';
import type { DataScope } from '../common/enums.js'; import type { DataScope } from '../common/enums.js';
import type { RequestContext } from '../common/context/request-context.js'; import type { RequestContext } from '../common/context/request-context.js';
@ -140,6 +140,25 @@ export class AuthService {
return { id: user.id, username: user.username }; return { id: user.id, username: user.username };
} }
/** 修改本人密码:校验原密码,更新后吊销其它会话。 */
async changePassword(tenantCode: string, userId: string, dto: ChangePasswordDto): Promise<null> {
const user = await this.prisma.client.user.findFirst({
where: { tenantCode, id: userId, deletedAt: null },
});
if (!user) throw new UnauthorizedException('账号不存在');
const ok = await bcrypt.compare(dto.oldPassword, user.passwordHash);
if (!ok) throw new BadRequestException('原密码错误');
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
await this.prisma.client.user.updateMany({
where: { tenantCode, id: userId },
data: { passwordHash, tokenVersion: { increment: 1 } },
});
await this.prisma.client.refreshToken.deleteMany({ where: { tenantCode, userId } });
await this.redis.del(permCacheKey(tenantCode, userId));
await this.redis.del(userStateCacheKey(tenantCode, userId));
return null;
}
async refresh(dto: RefreshDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { async refresh(dto: RefreshDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const record = await this.prisma.client.refreshToken.findUnique({ where: { jti: dto.refreshToken } }); const record = await this.prisma.client.refreshToken.findUnique({ where: { jti: dto.refreshToken } });
if (!record || record.revokedAt || record.expireAt.getTime() < Date.now()) { if (!record || record.revokedAt || record.expireAt.getTime() < Date.now()) {

View File

@ -45,6 +45,18 @@ export class RegisterDto {
phone?: string; phone?: string;
} }
export class ChangePasswordDto {
@ApiProperty({ description: '原密码' })
@IsString()
@IsNotEmpty()
oldPassword!: string;
@ApiProperty({ description: '新密码' })
@IsString()
@IsNotEmpty()
newPassword!: string;
}
export class RefreshDto { export class RefreshDto {
@ApiProperty({ description: '刷新令牌' }) @ApiProperty({ description: '刷新令牌' })
@IsString() @IsString()