From d58d52031991f86c4fce62e355fb200493c7fb95 Mon Sep 17 00:00:00 2001 From: ustaxs Date: Tue, 16 Jun 2026 16:47:47 +0100 Subject: [PATCH 1/4] Implement proper password reset flow --- api/src/auth/auth.controller.ts | 55 ++++++- api/src/auth/auth.module.ts | 10 +- api/src/auth/auth.service.spec.ts | 77 ++++++++- api/src/auth/auth.service.ts | 16 +- api/src/auth/dto/forgot-password.dto.ts | 12 ++ api/src/auth/dto/reset-password.dto.ts | 27 ++++ api/src/auth/password-reset.service.ts | 146 ++++++++++++++++++ api/src/auth/users.repository.ts | 31 +++- ...6061502_add_password_reset_tokens.down.sql | 11 ++ ...026061502_add_password_reset_tokens.up.sql | 25 +++ 10 files changed, 389 insertions(+), 21 deletions(-) create mode 100644 api/src/auth/dto/forgot-password.dto.ts create mode 100644 api/src/auth/dto/reset-password.dto.ts create mode 100644 api/src/auth/password-reset.service.ts create mode 100644 database/migrations/2026061502_add_password_reset_tokens.down.sql create mode 100644 database/migrations/2026061502_add_password_reset_tokens.up.sql diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index 0a5ad44..e6a1b17 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -1,14 +1,15 @@ +import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common" import { - Body, - Controller, - HttpCode, - HttpStatus, - Post, -} from "@nestjs/common" -import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger" + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from "@nestjs/swagger" import { AuthResponse, AuthService } from "./auth.service" import { LoginDto } from "./dto/login.dto" import { RegisterDto } from "./dto/register.dto" +import { ForgotPasswordDto } from "./dto/forgot-password.dto" +import { ResetPasswordDto } from "./dto/reset-password.dto" @ApiTags("auth") @Controller("auth") @@ -43,4 +44,44 @@ export class AuthController { login(@Body() dto: LoginDto): Promise { return this.authService.login(dto) } + + @Post("forgot-password") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Request a password reset", + description: + "Accepts an email address and sends password reset instructions if the account exists. Always returns success to avoid email enumeration.", + }) + @ApiOkResponse({ + description: + "Password reset instructions will be sent if the email exists.", + }) + async forgotPassword( + @Body() dto: ForgotPasswordDto, + ): Promise<{ message: string }> { + await this.authService.forgotPassword(dto) + return { + message: + "If the email address exists, password reset instructions have been sent.", + } + } + + @Post("reset-password") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Reset a forgotten password", + description: + "Accepts a password reset token and a new password. Rejects invalid, expired, or already-used tokens.", + }) + @ApiOkResponse({ + description: "Password reset successful.", + }) + async resetPassword( + @Body() dto: ResetPasswordDto, + ): Promise<{ message: string }> { + await this.authService.resetPassword(dto) + return { + message: "Password has been reset successfully.", + } + } } diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts index 6f14613..4ae5bda 100644 --- a/api/src/auth/auth.module.ts +++ b/api/src/auth/auth.module.ts @@ -1,20 +1,24 @@ -import { Module } from "@nestjs/common" -import { JwtModule } from "@nestjs/jwt" +import { CacheModule, Module } from "@nestjs/common"import { CacheModule } from "@nestjs/cache-manager"import { JwtModule } from "@nestjs/jwt" import { AuthController } from "./auth.controller" import { AuthService } from "./auth.service" +import { PasswordResetService } from "./password-reset.service" import { UsersRepository } from "./users.repository" const JWT_EXPIRES_IN = "15m" @Module({ imports: [ + CacheModule.register({ + ttl: 3600, + max: 1024, + }), JwtModule.register({ secret: process.env.JWT_SECRET ?? "dev-secret-change-me", signOptions: { expiresIn: JWT_EXPIRES_IN }, }), ], controllers: [AuthController], - providers: [AuthService, UsersRepository], + providers: [AuthService, PasswordResetService, UsersRepository], exports: [AuthService, JwtModule], }) export class AuthModule {} diff --git a/api/src/auth/auth.service.spec.ts b/api/src/auth/auth.service.spec.ts index a47c420..37567ed 100644 --- a/api/src/auth/auth.service.spec.ts +++ b/api/src/auth/auth.service.spec.ts @@ -23,6 +23,11 @@ interface MockUsersRepository { create: jest.Mock> } +interface MockPasswordResetService { + sendResetToken: jest.Mock> + resetPassword: jest.Mock> +} + function mockJwtService(): MockJwtService { return { sign: jest.fn(), @@ -37,13 +42,22 @@ function mockUsersRepository(): MockUsersRepository { } } +function mockPasswordResetService(): MockPasswordResetService { + return { + sendResetToken: jest.fn(), + resetPassword: jest.fn(), + } +} + function makeService( jwt: MockJwtService, users: MockUsersRepository, + passwordReset: MockPasswordResetService, ): AuthService { return new AuthService( jwt as unknown as JwtService, users as unknown as UsersRepository, + passwordReset as unknown as any, ) } @@ -66,12 +80,14 @@ function dummyUser(overrides: Partial = {}): User { describe("AuthService", () => { let jwt: MockJwtService let users: MockUsersRepository + let passwordReset: MockPasswordResetService let service: AuthService beforeEach(() => { jwt = mockJwtService() users = mockUsersRepository() - service = makeService(jwt, users) + passwordReset = mockPasswordResetService() + service = makeService(jwt, users, passwordReset) jest.clearAllMocks() }) @@ -87,7 +103,9 @@ describe("AuthService", () => { it("creates a user and returns an access token with user profile", async () => { users.findByEmail.mockResolvedValue(null) users.findByUsername.mockResolvedValue(null) - users.create.mockResolvedValue(dummyUser({ email: dto.email, username: dto.username })) + users.create.mockResolvedValue( + dummyUser({ email: dto.email, username: dto.username }), + ) jwt.sign.mockReturnValue("jwt.token.here") ;(bcrypt.hash as jest.Mock).mockResolvedValue("$2b$10$hashed") @@ -123,7 +141,9 @@ describe("AuthService", () => { it("throws ConflictException when the username is already taken", async () => { users.findByEmail.mockResolvedValue(null) - users.findByUsername.mockResolvedValue(dummyUser({ username: dto.username })) + users.findByUsername.mockResolvedValue( + dummyUser({ username: dto.username }), + ) await expect(service.register(dto)).rejects.toThrow(ConflictException) expect(users.create).not.toHaveBeenCalled() @@ -139,7 +159,8 @@ describe("AuthService", () => { await service.register(dto) expect(bcrypt.hash).toHaveBeenCalledWith(dto.password, 12) - const [storedUsername, storedEmail, storedHash] = users.create.mock.calls[0] + const [storedUsername, storedEmail, storedHash] = + users.create.mock.calls[0] expect(storedUsername).toBe(dto.username) expect(storedEmail).toBe(dto.email) expect(storedHash).toBe("$2b$10$hashed") @@ -149,11 +170,47 @@ describe("AuthService", () => { users.findByEmail.mockResolvedValue(dummyUser({ email: "dup@x.com" })) await expect( - service.register({ username: "dupuser", email: "dup@x.com", password: "someOtherPassword" }), + service.register({ + username: "dupuser", + email: "dup@x.com", + password: "someOtherPassword", + }), ).rejects.toThrow(ConflictException) }) }) + // -- forgot password -------------------------------------------------- + + describe("forgotPassword", () => { + it("delegates reset requests to the password reset service", async () => { + const dto = { email: "user@x.com" } + passwordReset.sendResetToken.mockResolvedValue(undefined) + + await service.forgotPassword(dto) + + expect(passwordReset.sendResetToken).toHaveBeenCalledWith(dto.email) + }) + }) + + // -- reset password --------------------------------------------------- + + describe("resetPassword", () => { + it("delegates password resets to the password reset service", async () => { + const dto = { + token: "reset-token", + password: "NewP4ssw0rd!", + } + passwordReset.resetPassword.mockResolvedValue(undefined) + + await service.resetPassword(dto) + + expect(passwordReset.resetPassword).toHaveBeenCalledWith( + dto.token, + dto.password, + ) + }) + }) + // -- login ------------------------------------------------------------- describe("login", () => { @@ -168,7 +225,10 @@ describe("AuthService", () => { const result = await service.login(dto) expect(users.findByEmail).toHaveBeenCalledWith(dto.email) - expect(bcrypt.compare).toHaveBeenCalledWith(dto.password, user.password_hash) + expect(bcrypt.compare).toHaveBeenCalledWith( + dto.password, + user.password_hash, + ) expect(jwt.sign).toHaveBeenCalledWith({ sub: user.id, email: dto.email, @@ -229,7 +289,10 @@ describe("AuthService", () => { await service.login(dto) - expect(bcrypt.compare).toHaveBeenCalledWith(dto.password, user.password_hash) + expect(bcrypt.compare).toHaveBeenCalledWith( + dto.password, + user.password_hash, + ) expect(jwt.sign).toHaveBeenCalledWith({ sub: user.id, email: dto.email, diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index a4ff1cf..dcb9cf0 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -7,7 +7,10 @@ import { JwtService } from "@nestjs/jwt" import * as bcrypt from "bcrypt" import { RegisterDto } from "./dto/register.dto" import { LoginDto } from "./dto/login.dto" +import { ForgotPasswordDto } from "./dto/forgot-password.dto" +import { ResetPasswordDto } from "./dto/reset-password.dto" import { User, UsersRepository } from "./users.repository" +import { PasswordResetService } from "./password-reset.service" /** Rounds for bcrypt key derivation (auto-salt). */ const BCRYPT_ROUNDS = 12 @@ -30,6 +33,7 @@ export class AuthService { constructor( private readonly jwtService: JwtService, private readonly usersRepository: UsersRepository, + private readonly passwordResetService: PasswordResetService, ) {} /** @@ -88,18 +92,28 @@ export class AuthService { } } + async forgotPassword(dto: ForgotPasswordDto): Promise { + await this.passwordResetService.sendResetToken(dto.email) + } + + async resetPassword(dto: ResetPasswordDto): Promise { + await this.passwordResetService.resetPassword(dto.token, dto.password) + } + /** Create a short-lived JWT access token for the given user. */ private signToken(user: User): string { return this.jwtService.sign({ sub: user.id, email: user.email, username: user.username, + passwordChangedAt: + user.password_changed_at?.getTime() ?? user.created_at.getTime(), }) } } /** Strip the password hash from a user row before returning to clients. */ -function toSafeUser(row: User): SafeUser { +export function toSafeUser(row: User): SafeUser { return { id: row.id, username: row.username, diff --git a/api/src/auth/dto/forgot-password.dto.ts b/api/src/auth/dto/forgot-password.dto.ts new file mode 100644 index 0000000..405f9a4 --- /dev/null +++ b/api/src/auth/dto/forgot-password.dto.ts @@ -0,0 +1,12 @@ +import { IsEmail } from "class-validator" +import { ApiProperty } from "@nestjs/swagger" + +/** Payload accepted by `POST /auth/forgot-password`. */ +export class ForgotPasswordDto { + @ApiProperty({ + description: "Registered email address for account recovery.", + example: "user@example.com", + }) + @IsEmail() + email!: string +} diff --git a/api/src/auth/dto/reset-password.dto.ts b/api/src/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..858ec9c --- /dev/null +++ b/api/src/auth/dto/reset-password.dto.ts @@ -0,0 +1,27 @@ +import { IsString, Length, Matches } from "class-validator" +import { ApiProperty } from "nestjs/swagger" + +/** Payload accepted by `POST /auth/reset-password`. */ +export class ResetPasswordDto { + @ApiProperty({ + description: "Password reset token received in the email.", + example: "4hB8r9v0Q2uLmT...", + }) + @IsString() + token!: string + + @ApiProperty({ + description: + "New password (minimum 8 characters, at least one letter and one digit).", + example: "NewP4ssw0rd!", + }) + @IsString() + @Length(8, 128) + @Matches(/[A-Za-z]/, { + message: "password must contain at least one letter", + }) + @Matches(/[0-9]/, { + message: "password must contain at least one digit", + }) + password!: string +} diff --git a/api/src/auth/password-reset.service.ts b/api/src/auth/password-reset.service.ts new file mode 100644 index 0000000..b68b66a --- /dev/null +++ b/api/src/auth/password-reset.service.ts @@ -0,0 +1,146 @@ +import { + BadRequestException, + CACHE_MANAGER, + Inject, + Injectable, + Logger, +} from "@nestjs/common" +import { Cache } from "cache-manager" +import { Pool } from "pg" +import * as bcrypt from "bcrypt" +import * as crypto from "crypto" +import { UsersRepository } from "./users.repository" + +const PASSWORD_RESET_TOKEN_BYTES = 32 +const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000 +const MAX_ATTEMPTS_PER_EMAIL = 3 +const RATE_LIMIT_TTL_SECONDS = 60 * 60 +const BCRYPT_ROUNDS = 12 + +@Injectable() +export class PasswordResetService { + private readonly pool = new Pool({ + connectionString: process.env.DATABASE_URL, + }) + private readonly logger = new Logger(PasswordResetService.name) + + constructor( + private readonly usersRepository: UsersRepository, + @Inject(CACHE_MANAGER) private readonly cache: Cache, + ) {} + + async sendResetToken(email: string): Promise { + const normalizedEmail = email.trim().toLowerCase() + const rateKey = `password-reset:forgot:${normalizedEmail}` + + const currentAttempts = (await this.cache.get(rateKey)) ?? 0 + if (currentAttempts >= MAX_ATTEMPTS_PER_EMAIL) { + this.logger.warn( + `Password reset request rate limited for ${normalizedEmail}`, + ) + return + } + await this.cache.set(rateKey, currentAttempts + 1, { + ttl: RATE_LIMIT_TTL_SECONDS, + }) + + const user = await this.usersRepository.findByEmail(normalizedEmail) + if (!user) { + return + } + + const token = await this.createResetToken(user.id) + await this.sendResetEmail(normalizedEmail, token) + } + + async resetPassword(token: string, password: string): Promise { + const tokenHash = this.hashToken(token) + const { rows } = await this.pool.query( + `SELECT id, user_id + FROM password_reset_tokens + WHERE token_hash = $1 + AND used = false + AND expires_at > NOW()`, + [tokenHash], + ) + + const row = rows[0] + if (!row) { + throw new BadRequestException("invalid or expired reset token") + } + + const user = await this.usersRepository.findById(row.user_id) + if (!user) { + throw new BadRequestException("invalid or expired reset token") + } + + const normalizedEmail = user.email.trim().toLowerCase() + const rateKey = `password-reset:reset:${normalizedEmail}` + const currentAttempts = (await this.cache.get(rateKey)) ?? 0 + if (currentAttempts >= MAX_ATTEMPTS_PER_EMAIL) { + this.logger.warn( + `Password reset execution rate limited for ${normalizedEmail}`, + ) + throw new BadRequestException("invalid or expired reset token") + } + await this.cache.set(rateKey, currentAttempts + 1, { + ttl: RATE_LIMIT_TTL_SECONDS, + }) + + const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS) + const passwordChangedAt = new Date() + + const client = await this.pool.connect() + try { + await client.query("BEGIN") + await client.query( + `UPDATE password_reset_tokens + SET used = true + WHERE id = $1`, + [row.id], + ) + await client.query( + `UPDATE users + SET password_hash = $1, + password_changed_at = $2 + WHERE id = $3`, + [passwordHash, passwordChangedAt, row.user_id], + ) + await client.query("COMMIT") + } catch (error) { + await client.query("ROLLBACK") + throw error + } finally { + client.release() + } + } + + private async createResetToken(userId: number): Promise { + const token = crypto.randomBytes(PASSWORD_RESET_TOKEN_BYTES).toString("hex") + const tokenHash = this.hashToken(token) + const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN_TTL_MS) + + await this.pool.query( + `INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, $3)`, + [userId, tokenHash, expiresAt], + ) + + return token + } + + private hashToken(token: string): string { + return crypto.createHash("sha256").update(token, "utf8").digest("hex") + } + + private async sendResetEmail(email: string, token: string): Promise { + const resetUrlBase = process.env.RESET_PASSWORD_URL_BASE + const resetUrl = resetUrlBase + ? `${resetUrlBase.replace(/\/?$/, "")}?token=${encodeURIComponent(token)}` + : token + + this.logger.log( + `Password reset token ready for ${email}. Use the token or URL: ${resetUrl}`, + ) + } +} diff --git a/api/src/auth/users.repository.ts b/api/src/auth/users.repository.ts index a585ca1..62b79f5 100644 --- a/api/src/auth/users.repository.ts +++ b/api/src/auth/users.repository.ts @@ -7,6 +7,7 @@ export interface User { email: string password_hash: string created_at: Date + password_changed_at?: Date } /** @@ -21,7 +22,7 @@ export class UsersRepository { async findByEmail(email: string): Promise { const { rows } = await this.pool.query( - "SELECT id, username, email, password_hash, created_at FROM users WHERE email = $1", + "SELECT id, username, email, password_hash, created_at, password_changed_at FROM users WHERE email = $1", [email], ) return rows[0] ?? null @@ -29,12 +30,20 @@ export class UsersRepository { async findByUsername(username: string): Promise { const { rows } = await this.pool.query( - "SELECT id, username, email, password_hash, created_at FROM users WHERE username = $1", + "SELECT id, username, email, password_hash, created_at, password_changed_at FROM users WHERE username = $1", [username], ) return rows[0] ?? null } + async findById(id: number): Promise { + const { rows } = await this.pool.query( + "SELECT id, username, email, password_hash, created_at, password_changed_at FROM users WHERE id = $1", + [id], + ) + return rows[0] ?? null + } + async create( username: string, email: string, @@ -43,9 +52,25 @@ export class UsersRepository { const { rows } = await this.pool.query( `INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3) - RETURNING id, username, email, password_hash, created_at`, + RETURNING id, username, email, password_hash, created_at, password_changed_at`, [username, email, passwordHash], ) return rows[0] } + + async updatePasswordHash( + id: number, + passwordHash: string, + passwordChangedAt: Date, + ): Promise { + const { rows } = await this.pool.query( + `UPDATE users + SET password_hash = $1, + password_changed_at = $2 + WHERE id = $3 + RETURNING id, username, email, password_hash, created_at, password_changed_at`, + [passwordHash, passwordChangedAt, id], + ) + return rows[0] + } } diff --git a/database/migrations/2026061502_add_password_reset_tokens.down.sql b/database/migrations/2026061502_add_password_reset_tokens.down.sql new file mode 100644 index 0000000..1bd8e2d --- /dev/null +++ b/database/migrations/2026061502_add_password_reset_tokens.down.sql @@ -0,0 +1,11 @@ +-- Migration: 2026061502_add_password_reset_tokens (DOWN) +-- +-- Reverses 2026061502_add_password_reset_tokens.up.sql. + +BEGIN; + +DROP TABLE IF EXISTS password_reset_tokens; + +ALTER TABLE users DROP COLUMN IF EXISTS password_changed_at; + +COMMIT; diff --git a/database/migrations/2026061502_add_password_reset_tokens.up.sql b/database/migrations/2026061502_add_password_reset_tokens.up.sql new file mode 100644 index 0000000..12fec93 --- /dev/null +++ b/database/migrations/2026061502_add_password_reset_tokens.up.sql @@ -0,0 +1,25 @@ +-- Migration: 2026061502_add_password_reset_tokens (UP) +-- +-- Adds the password reset token store and tracks password change time +-- so old JWTs can be invalidated after a password reset. + +BEGIN; + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMP; + +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMP NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_password_reset_token_hash + ON password_reset_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_password_reset_expires_at + ON password_reset_tokens(expires_at); + +COMMIT; From edd680aa82d1a85b012798d33e11a21cb1ac63b1 Mon Sep 17 00:00:00 2001 From: ustaxs Date: Tue, 16 Jun 2026 17:14:04 +0100 Subject: [PATCH 2/4] Implement proper password reset flow --- api/src/auth/auth.module.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts index 4ae5bda..207d69c 100644 --- a/api/src/auth/auth.module.ts +++ b/api/src/auth/auth.module.ts @@ -1,4 +1,6 @@ -import { CacheModule, Module } from "@nestjs/common"import { CacheModule } from "@nestjs/cache-manager"import { JwtModule } from "@nestjs/jwt" +import { Module } from "@nestjs/common" +import { CacheModule } from "@nestjs/cache-manager" +import { JwtModule } from "@nestjs/jwt" import { AuthController } from "./auth.controller" import { AuthService } from "./auth.service" import { PasswordResetService } from "./password-reset.service" From df6d0aca056cb2f09da2c1d465aff01dafac796f Mon Sep 17 00:00:00 2001 From: ustaxs Date: Tue, 16 Jun 2026 17:20:21 +0100 Subject: [PATCH 3/4] Implement proper password reset flow --- api/src/auth/auth.module.ts | 2 +- api/src/auth/dto/reset-password.dto.ts | 2 +- api/src/auth/password-reset.service.ts | 17 ++++------------- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts index 207d69c..fa2d23c 100644 --- a/api/src/auth/auth.module.ts +++ b/api/src/auth/auth.module.ts @@ -3,8 +3,8 @@ import { CacheModule } from "@nestjs/cache-manager" import { JwtModule } from "@nestjs/jwt" import { AuthController } from "./auth.controller" import { AuthService } from "./auth.service" -import { PasswordResetService } from "./password-reset.service" import { UsersRepository } from "./users.repository" +import { PasswordResetService } from "./password-reset.service" const JWT_EXPIRES_IN = "15m" diff --git a/api/src/auth/dto/reset-password.dto.ts b/api/src/auth/dto/reset-password.dto.ts index 858ec9c..1ec388e 100644 --- a/api/src/auth/dto/reset-password.dto.ts +++ b/api/src/auth/dto/reset-password.dto.ts @@ -1,5 +1,5 @@ import { IsString, Length, Matches } from "class-validator" -import { ApiProperty } from "nestjs/swagger" +import { ApiProperty } from "@nestjs/swagger" /** Payload accepted by `POST /auth/reset-password`. */ export class ResetPasswordDto { diff --git a/api/src/auth/password-reset.service.ts b/api/src/auth/password-reset.service.ts index b68b66a..49d0dcc 100644 --- a/api/src/auth/password-reset.service.ts +++ b/api/src/auth/password-reset.service.ts @@ -1,10 +1,5 @@ -import { - BadRequestException, - CACHE_MANAGER, - Inject, - Injectable, - Logger, -} from "@nestjs/common" +import { BadRequestException, Inject, Injectable, Logger } from "@nestjs/common" +import { CACHE_MANAGER } from "@nestjs/cache-manager" import { Cache } from "cache-manager" import { Pool } from "pg" import * as bcrypt from "bcrypt" @@ -40,9 +35,7 @@ export class PasswordResetService { ) return } - await this.cache.set(rateKey, currentAttempts + 1, { - ttl: RATE_LIMIT_TTL_SECONDS, - }) + await this.cache.set(rateKey, currentAttempts + 1, RATE_LIMIT_TTL_SECONDS) const user = await this.usersRepository.findByEmail(normalizedEmail) if (!user) { @@ -83,9 +76,7 @@ export class PasswordResetService { ) throw new BadRequestException("invalid or expired reset token") } - await this.cache.set(rateKey, currentAttempts + 1, { - ttl: RATE_LIMIT_TTL_SECONDS, - }) + await this.cache.set(rateKey, currentAttempts + 1, RATE_LIMIT_TTL_SECONDS) const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS) const passwordChangedAt = new Date() From f86d7cfd1fa6fa872e4a27246293a7c992fb1b11 Mon Sep 17 00:00:00 2001 From: ustaxs Date: Tue, 16 Jun 2026 17:26:21 +0100 Subject: [PATCH 4/4] fixed workflow errors --- api/src/auth/auth.service.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/src/auth/auth.service.spec.ts b/api/src/auth/auth.service.spec.ts index 37567ed..32c7932 100644 --- a/api/src/auth/auth.service.spec.ts +++ b/api/src/auth/auth.service.spec.ts @@ -122,6 +122,7 @@ describe("AuthService", () => { sub: 1, email: dto.email, username: dto.username, + passwordChangedAt: expect.any(Number), }) expect(result.accessToken).toBe("jwt.token.here") expect(result.user).toEqual({ @@ -233,6 +234,7 @@ describe("AuthService", () => { sub: user.id, email: dto.email, username: user.username, + passwordChangedAt: expect.any(Number), }) expect(result.accessToken).toBe("jwt.token.here") expect(result.user).toEqual({ @@ -297,6 +299,7 @@ describe("AuthService", () => { sub: user.id, email: dto.email, username: user.username, + passwordChangedAt: expect.any(Number), }) }) })