fix: complete first version issue remediation

This commit is contained in:
hectorzhao
2026-07-11 10:14:37 +08:00
parent 709ac97764
commit 208a6c23f8
73 changed files with 1549 additions and 245 deletions
+12 -2
View File
@@ -1,11 +1,13 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { Body, Controller, Get, Post, UnauthorizedException } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from './current-session-user.decorator';
import { AuthService, LoginDto } from './auth.service';
import { UsersService } from '../users/users.service';
@ApiTags('auth')
@Controller()
export class AuthController {
constructor(private readonly auth: AuthService) {}
constructor(private readonly auth: AuthService, private readonly users: UsersService) {}
@Get('admin/auth/captcha')
adminCaptcha() {
@@ -26,4 +28,12 @@ export class AuthController {
clientLogin(@Body() body: LoginDto) {
return this.auth.login(body, 'client');
}
@Post('auth/password')
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
if (!userId) {
throw new UnauthorizedException('登录会话无效,请重新登录');
}
return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? '');
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ describe('AuthService', () => {
it('allows platform admins to login admin portal', async () => {
const users = createUsersMock('platform_admin');
const service = new AuthService(users as never);
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin' }));
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin', accessToken: 'dev-token:user-1:0' }));
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
});
+1 -1
View File
@@ -77,7 +77,7 @@ export class AuthService {
anonymousFailures.delete(login);
return {
accessToken: `dev-token-${user.id}`,
accessToken: `dev-token:${user.id}:${user.sessionVersion ?? 0}`,
tokenType: 'Bearer',
portal,
user: {
@@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { SessionRequest } from './session-validation.middleware';
export const CurrentSessionUserId = createParamDecorator((_: unknown, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest<SessionRequest>();
return request.sessionUserId;
});
@@ -0,0 +1,27 @@
import { UnauthorizedException } from '@nestjs/common';
import { SessionValidationMiddleware, type SessionRequest } from './session-validation.middleware';
function request(authorization?: string): SessionRequest {
return { header: jest.fn().mockReturnValue(authorization) };
}
describe('SessionValidationMiddleware', () => {
it('accepts the current user session version and exposes the session user id', async () => {
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const middleware = new SessionValidationMiddleware(prisma as never);
const currentRequest = request('Bearer dev-token:user-1:3');
const next = jest.fn();
await middleware.use(currentRequest, {} as never, next);
expect(next).toHaveBeenCalledTimes(1);
expect(currentRequest.sessionUserId).toBe('user-1');
});
it('rejects an old session token after the user session version changes', async () => {
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 4 }) } };
const middleware = new SessionValidationMiddleware(prisma as never);
await expect(middleware.use(request('Bearer dev-token:user-1:3'), {} as never, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
});
});
@@ -0,0 +1,33 @@
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export type SessionRequest = {
header(name: string): string | undefined;
sessionUserId?: string;
};
@Injectable()
export class SessionValidationMiddleware implements NestMiddleware {
constructor(private readonly prisma: PrismaService) {}
async use(request: SessionRequest, _: unknown, next: () => void) {
const authorization = request.header('authorization');
if (!authorization) {
next();
return;
}
const match = /^Bearer dev-token:([^:]+):(\d+)$/.exec(authorization.trim());
if (!match) {
throw new UnauthorizedException('登录会话无效,请重新登录');
}
const user = await this.prisma.user.findUnique({
where: { id: match[1] },
select: { id: true, status: true, deletedAt: true, sessionVersion: true },
});
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== Number(match[2])) {
throw new UnauthorizedException('登录会话已失效,请重新登录');
}
request.sessionUserId = user.id;
next();
}
}