From 601f45492911bb3cd924df9ec77ed6f81efbbc26 Mon Sep 17 00:00:00 2001 From: soorq Date: Thu, 7 May 2026 21:59:59 +0300 Subject: [PATCH] refactor(auth): unify token expiration and session creation * Fix: incorrect expiresAt in cookies/session (now synced with JWT) * Refactor: eliminate redundant DB update during sign-in * Security: use CUID/UUID for sessionId to prevent collisions * Sync: tokenService now provides exact expiration from JWT payload --- .../application/controller/auth/controller.ts | 28 +++++++++++-------- .../application/controller/auth/swagger.ts | 26 +++-------------- src/auth/application/dtos/auth.dto.ts | 7 +++++ .../use-cases/refresh-tokens.use-case.ts | 18 +++++++----- .../use-cases/reset-password.use-case.ts | 24 ++++++++++++++-- .../application/use-cases/sign-in.use-case.ts | 14 +++++++--- .../use-cases/sign-up-verify.use-case.ts | 14 ++++++++-- .../infrastructure/security/token.service.ts | 11 ++++++-- src/shared/error/filter.ts | 2 +- 9 files changed, 90 insertions(+), 54 deletions(-) diff --git a/src/auth/application/controller/auth/controller.ts b/src/auth/application/controller/auth/controller.ts index 258c59c..f74bc1f 100644 --- a/src/auth/application/controller/auth/controller.ts +++ b/src/auth/application/controller/auth/controller.ts @@ -16,6 +16,9 @@ import { ConfigService } from '@nestjs/config'; @ApiBaseController('auth', 'Auth') export class AuthController { + private readonly isProduction: boolean = false; + private readonly domain: string | null = null; + constructor( private readonly facade: AuthFacade, private cfg: ConfigService, @@ -24,9 +27,6 @@ export class AuthController { this.domain = this.cfg.get('DOMAIN'); } - private readonly isProduction: boolean; - private readonly domain: string; - @Post('sign-up') @PostRegisterSwagger() @HttpCode(202) @@ -43,9 +43,9 @@ export class AuthController { @Body() dto: VerifyDto, ) { const meta = getDeviceMeta(req); - const { tokens, ...response } = await this.facade.verifySignUp(dto, meta); + const { tokens, expiresAt, ...response } = await this.facade.verifySignUp(dto, meta); - this.setRefreshCookie(res, tokens.refresh); + this.setRefreshCookie(res, tokens.refresh, expiresAt); return { ...response, token: tokens.access }; } @@ -58,9 +58,9 @@ export class AuthController { @Body() dto: SignInDto, ) { const meta = getDeviceMeta(req); - const { tokens, ...response } = await this.facade.signIn(dto, meta); + const { tokens, expiresAt, ...response } = await this.facade.signIn(dto, meta); - this.setRefreshCookie(res, tokens.refresh); + this.setRefreshCookie(res, tokens.refresh, expiresAt); return { ...response, token: tokens.access }; } @@ -73,7 +73,10 @@ export class AuthController { const session = req.cookies?.['refresh']; const response = await this.facade.signOut(session); - res.clearCookie('refresh', { path: '/', domain: `.${this.domain}` }); + res.clearCookie('refresh', { + path: '/', + domain: this.domain ? `.${this.domain}` : undefined, + }); return response; } @@ -85,20 +88,21 @@ export class AuthController { async refresh(@Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest) { const meta = getDeviceMeta(req); const session = req.cookies?.['refresh']; - const { tokens, ...response } = await this.facade.refreshTokens(session, meta); + const { tokens, expiresAt, ...response } = await this.facade.refreshTokens(session, meta); - this.setRefreshCookie(res, tokens.refresh); + this.setRefreshCookie(res, tokens.refresh, expiresAt); return { token: tokens.access, ...response }; } - private setRefreshCookie(res: FastifyReply, refreshToken: string) { + private setRefreshCookie(res: FastifyReply, refreshToken: string, expires: Date) { res.setCookie('refresh', refreshToken, { httpOnly: true, secure: this.isProduction, path: '/', + expires, sameSite: 'lax', - domain: `.${this.domain}`, + domain: this.domain ? `.${this.domain}` : undefined, }); } } diff --git a/src/auth/application/controller/auth/swagger.ts b/src/auth/application/controller/auth/swagger.ts index 41ff77a..bf429b9 100644 --- a/src/auth/application/controller/auth/swagger.ts +++ b/src/auth/application/controller/auth/swagger.ts @@ -8,7 +8,7 @@ import { ApiUnauthorized, ApiValidationError, } from '@shared/error'; -import { SignInDto, SignUpDto, VerifyDto } from '../../dtos'; +import { SignInDto, SignResponse, SignUpDto, VerifyDto } from '../../dtos'; import { ActionResponse } from '@shared/dtos'; export const PostRegisterSwagger = () => @@ -38,13 +38,7 @@ export const PostLoginSwagger = () => ApiResponse({ status: 200, description: 'Успешный вход.', - schema: { - example: { - success: true, - message: false, - token: 'eyJhbGciOiJIUzI1NiIsInR5c...', - }, - }, + type: SignResponse.Output, }), ApiBadRequest('Неверный формат email'), ApiUnauthorized('Неверный email или пароль'), @@ -59,13 +53,7 @@ export const PostRefreshSwagger = () => ApiResponse({ status: 200, description: 'Токены успешно обновлены.', - schema: { - example: { - success: true, - token: 'eyJhbGciOiJIUzI1NiIsInR5c...', - message: 'def50200508a1768c7e...', - }, - }, + type: SignResponse.Output, }), ApiBadRequest('Ошибка валидации (не передан refresh токен)'), ApiUnauthorized('Refresh токен недействителен, истек или отозван'), @@ -92,13 +80,7 @@ export const PostSignUpConfirmSwagger = () => ApiResponse({ status: 201, description: 'Аккаунт подтверждён, сессия создана.', - schema: { - example: { - success: true, - message: 'Аккаунт успешно подтвержден', - token: 'eyJhbGciOiJIUzI1NiIsInR5c...', - }, - }, + type: SignResponse.Output, }), ApiValidationError('Ошибка валидации (неверный формат email или длина кода)'), ApiBadRequest('Срок регистрации истёк или сессия не найдена'), diff --git a/src/auth/application/dtos/auth.dto.ts b/src/auth/application/dtos/auth.dto.ts index 5aeac05..0f3059a 100644 --- a/src/auth/application/dtos/auth.dto.ts +++ b/src/auth/application/dtos/auth.dto.ts @@ -1,3 +1,4 @@ +import { ActionResponseSchema } from '@shared/dtos'; import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; @@ -56,3 +57,9 @@ export const VerifySchema = z .describe('Схема верификации OTP кода'); export class VerifyDto extends createZodDto(VerifySchema) {} + +export const SignResponseSchema = ActionResponseSchema.extend({ + token: z.string().describe('JWT токен доступа пользователя'), +}); + +export class SignResponse extends createZodDto(SignResponseSchema) {} diff --git a/src/auth/application/use-cases/refresh-tokens.use-case.ts b/src/auth/application/use-cases/refresh-tokens.use-case.ts index f9a853e..4e3ad3a 100644 --- a/src/auth/application/use-cases/refresh-tokens.use-case.ts +++ b/src/auth/application/use-cases/refresh-tokens.use-case.ts @@ -4,6 +4,7 @@ import { ISessionRepository } from '../../domain/repository'; import { TokenService } from '../../infrastructure/security'; import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; import { FindUserQuery } from '@core/user'; +import { createId } from '@paralleldrive/cuid2'; @Injectable() export class RefreshTokensUseCase { @@ -54,20 +55,23 @@ export class RefreshTokensUseCase { await this.sessionRepo.revoke(session.id); - const newSession = await this.sessionRepo.create({ + const sessionId = createId(); + const { access, refresh, expiresAt } = await this.tokenService.generateTokens( + entity.user, + sessionId, + ); + + await this.sessionRepo.create({ + id: sessionId, userId: entity.user.id, ...metadata, - expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + expiresAt, }); - const { access, refresh } = await this.tokenService.generateTokens( - entity.user, - newSession.id, - ); - return { tokens: { access, refresh }, success: true, + expiresAt, message: 'Токены успешно обновлены', }; } diff --git a/src/auth/application/use-cases/reset-password.use-case.ts b/src/auth/application/use-cases/reset-password.use-case.ts index f930b56..9c1e0c6 100644 --- a/src/auth/application/use-cases/reset-password.use-case.ts +++ b/src/auth/application/use-cases/reset-password.use-case.ts @@ -21,9 +21,29 @@ export class ResetPasswordUseCase { ) {} async execute(dto: ResetPasswordDto) { + const redisKey = `pass:reset:${dto.email}`; + const isExistsAttempt = await this.redis.get(redisKey); + + if (isExistsAttempt) { + throw new BaseException( + { + code: 'PASS_RESET_ATTEMPT_ACTIVE', + message: + 'Запрос на сброс пароля уже активен. Проверьте почту или попробуйте позже.', + details: [ + { + target: 'email', + value: dto.email, + }, + ], + }, + HttpStatus.CONFLICT, + ); + } + const entity = await this.findUserQuery.execute({ email: dto.email }); - if (!entity.user) { + if (!entity?.user) { throw new BaseException( { code: 'USER_NOT_FOUND', @@ -48,7 +68,7 @@ export class ResetPasswordUseCase { isVerified: false, }; - await this.redis.set(`pass:reset:${dto.email}`, JSON.stringify(resetPayload), 'EX', 900); + await this.redis.set(redisKey, JSON.stringify(resetPayload), 'EX', 900); const event = new ResetPasswordEvent(dto.email, token); await this.mailQueue.add(AuthMailJobs.SEND_RESET_PASSWORD, event, { diff --git a/src/auth/application/use-cases/sign-in.use-case.ts b/src/auth/application/use-cases/sign-in.use-case.ts index 05c4d04..2c53dad 100644 --- a/src/auth/application/use-cases/sign-in.use-case.ts +++ b/src/auth/application/use-cases/sign-in.use-case.ts @@ -6,6 +6,7 @@ import { TokenService } from '../../infrastructure/security'; import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; import { SignInDto } from '../dtos'; import { FindUserQuery } from '@core/user'; +import { createId } from '@paralleldrive/cuid2'; @Injectable() export class SignInUseCase { @@ -41,21 +42,26 @@ export class SignInUseCase { HttpStatus.UNAUTHORIZED, ); } + const sessionId = createId(); + const { access, refresh, expiresAt } = await this.tokenService.generateTokens( + user, + sessionId, + ); - const { id } = await this.sessionRepo.create({ + await this.sessionRepo.create({ + id: sessionId, userId: user.id, - expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + expiresAt, ...meta, }); - const { access, refresh } = await this.tokenService.generateTokens(user, id); - return { success: true, tokens: { access, refresh, }, + expiresAt, message: 'Вы успешно вошли в систему', }; } diff --git a/src/auth/application/use-cases/sign-up-verify.use-case.ts b/src/auth/application/use-cases/sign-up-verify.use-case.ts index e0b1d61..c8fb836 100644 --- a/src/auth/application/use-cases/sign-up-verify.use-case.ts +++ b/src/auth/application/use-cases/sign-up-verify.use-case.ts @@ -8,6 +8,7 @@ import { ISessionRepository } from '../../domain/repository'; import { TokenService } from '../../infrastructure/security'; import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; import { VerifyDto } from '../dtos'; +import { createId } from '@paralleldrive/cuid2'; @Injectable() export class SignUpVerifyUseCase { @@ -72,18 +73,25 @@ export class SignUpVerifyUseCase { password: userData.password, }); - const session = await this.sessionRepo.create({ + const sessionId = createId(); + const { access, refresh, expiresAt } = await this.tokenService.generateTokens( + user, + sessionId, + ); + + await this.sessionRepo.create({ + id: sessionId, userId: user.id, - expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), ...meta, + expiresAt, }); - const { access, refresh } = await this.tokenService.generateTokens(user, session.id); await this.redis.del(redisKey); return { success: true, tokens: { access, refresh }, + expiresAt, message: 'Аккаунт успешно подтвержден', }; } diff --git a/src/auth/infrastructure/security/token.service.ts b/src/auth/infrastructure/security/token.service.ts index a3f2480..78ae7c0 100644 --- a/src/auth/infrastructure/security/token.service.ts +++ b/src/auth/infrastructure/security/token.service.ts @@ -23,18 +23,23 @@ export class TokenService { aud: btoa(audConstraint), }; + const accessExp = this.cfg.get('JWT_ACCESS_EXPIRES_IN'); + const refreshExp = this.cfg.get('JWT_REFRESH_EXPIRES_IN'); + const [access, refresh] = await Promise.all([ this.jwtService.signAsync(payload, { secret: this.cfg.get('JWT_ACCESS_SECRET'), - expiresIn: this.cfg.get('JWT_ACCESS_EXPIRES_IN'), + expiresIn: accessExp, }), this.jwtService.signAsync(payload, { secret: this.cfg.get('JWT_REFRESH_SECRET'), - expiresIn: this.cfg.get('JWT_REFRESH_EXPIRES_IN'), + expiresIn: refreshExp, }), ]); - return { access, refresh }; + const refreshDecodedData = this.jwtService.decode(refresh); + + return { access, refresh, expiresAt: new Date(refreshDecodedData?.exp * 1000) }; } async validateToken(token: string, type: 'access' | 'refresh'): Promise { diff --git a/src/shared/error/filter.ts b/src/shared/error/filter.ts index 2a8b778..1d6724b 100644 --- a/src/shared/error/filter.ts +++ b/src/shared/error/filter.ts @@ -80,7 +80,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { this.formatErrorResponse(request, status, { code: errorCode, message, - details: error?.constraint ? [{ target: error.constraint }] : [], + details: error?.detail ? [{ target: error.detail }] : [], stack: exception.stack, service: 'postgres', }),