From 1f0a27a2e16c91ccd104c0654b61491425bb4c77 Mon Sep 17 00:00:00 2001 From: Paul Coral Date: Mon, 11 Aug 2025 23:15:43 +0200 Subject: [PATCH] add password reset --- .../app/auth/auth-core/auth-core.service.ts | 12 ++++++++- .../src/app/auth/auth-core/auth-registry.ts | 25 +++++++++++++++--- .../auth-feature/auth-controller.service.ts | 26 +++++++++++++++++-- .../app/auth/auth-feature/auth.controller.ts | 9 ++----- .../users/users-core/users-core.service.ts | 13 ++++++++++ .../app/users/users-core/users-registry.ts | 15 +++++++++++ apps/backend/src/lib/crypto.ts | 14 +++++++++- .../LocalAPI/auth/reset password.bru | 23 ++++++++++++++++ 8 files changed, 123 insertions(+), 14 deletions(-) create mode 100644 documentation/bruno-rest-api/LocalAPI/auth/reset password.bru diff --git a/apps/backend/src/app/auth/auth-core/auth-core.service.ts b/apps/backend/src/app/auth/auth-core/auth-core.service.ts index f8bab70..3060765 100644 --- a/apps/backend/src/app/auth/auth-core/auth-core.service.ts +++ b/apps/backend/src/app/auth/auth-core/auth-core.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { AuthRegistry } from './auth-registry'; import { generateSecureToken } from '../../../lib/crypto'; import * as dayjs from 'dayjs'; +import { isDefined, ResetAuth } from '@my-monorepo/common'; const RESET_TOKEN_VALIDITY_MINUTES = 15; @@ -12,10 +13,19 @@ export class AuthCoreService { async addNewResetToken( userId: string, ): Promise<{ token: string; user_id: string }> { - return this.authRegistry.addResetToken({ + const res = await this.authRegistry.addResetToken({ token: generateSecureToken(), userId, validUntil: dayjs().add(RESET_TOKEN_VALIDITY_MINUTES, 'minutes'), }); + if (!isDefined(res)) { + throw new Error('cannot add reset token'); + } + return res; + } + + async checkAndInvalidateResetToken(resetAuth: ResetAuth): Promise { + const res = await this.authRegistry.checkAndInvalidateResetToken(resetAuth); + return res > 0; } } diff --git a/apps/backend/src/app/auth/auth-core/auth-registry.ts b/apps/backend/src/app/auth/auth-core/auth-registry.ts index cfc45ba..06afc35 100644 --- a/apps/backend/src/app/auth/auth-core/auth-registry.ts +++ b/apps/backend/src/app/auth/auth-core/auth-registry.ts @@ -1,6 +1,7 @@ +import { ResetAuth } from '@my-monorepo/common'; import { Injectable } from '@nestjs/common'; -import { DatabaseService } from '../../../services/core/database/database.service'; import * as dayjs from 'dayjs'; +import { DatabaseService } from '../../../services/core/database/database.service'; export interface CreateResetToken { userId: string; @@ -17,7 +18,7 @@ export class AuthRegistry { async addResetToken( resetAuth: CreateResetToken, - ): Promise<{ token: string; user_id: string }> { + ): Promise<{ token: string; user_id: string } | undefined> { return this.database .insertInto('reset_tokens') .values({ @@ -26,6 +27,24 @@ export class AuthRegistry { valid_until: resetAuth.validUntil.toDate(), }) .returning(['user_id', 'token']) - .executeTakeFirstOrThrow(); + .executeTakeFirst(); + } + + async checkAndInvalidateResetToken(resetAuth: ResetAuth): Promise { + const res = await this.database + .deleteFrom('reset_tokens') + .where('user_id', '=', resetAuth.userId) + .where('token', '=', resetAuth.resetToken) + .where('valid_until', '>=', dayjs().toDate()) + .executeTakeFirst(); + console.log( + this.database + .deleteFrom('reset_tokens') + .where('user_id', '=', resetAuth.userId) + .where('token', '=', resetAuth.resetToken) + .where('valid_until', '>=', dayjs().toDate()) + .compile(), + ); + return res.numDeletedRows; } } diff --git a/apps/backend/src/app/auth/auth-feature/auth-controller.service.ts b/apps/backend/src/app/auth/auth-feature/auth-controller.service.ts index eabc3b7..487d3e1 100644 --- a/apps/backend/src/app/auth/auth-feature/auth-controller.service.ts +++ b/apps/backend/src/app/auth/auth-feature/auth-controller.service.ts @@ -1,9 +1,19 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { UsersCoreService } from '../../users/users-core/users-core.service'; +import { AuthCoreService } from '../auth-core/auth-core.service'; +import { ResetAuth } from '@my-monorepo/common'; +import { hashPassword } from '../../../lib/crypto'; @Injectable() export class AuthControllerService { - constructor(private userCoreService: UsersCoreService) {} + constructor( + private userCoreService: UsersCoreService, + private authCoreService: AuthCoreService, + ) {} async verifyCredentials() { const isValidCredential = await this.userCoreService.existsEmailPassword(); @@ -11,4 +21,16 @@ export class AuthControllerService { throw new UnauthorizedException('invalid credentials'); } } + + async resetPassword(resetData: ResetAuth) { + const isValidToken = + await this.authCoreService.checkAndInvalidateResetToken(resetData); + if (!isValidToken) { + throw new NotFoundException(); + } + await this.userCoreService.setHashPassword( + resetData.userId, + await hashPassword(resetData.newPassword), + ); + } } diff --git a/apps/backend/src/app/auth/auth-feature/auth.controller.ts b/apps/backend/src/app/auth/auth-feature/auth.controller.ts index d05c42c..f509117 100644 --- a/apps/backend/src/app/auth/auth-feature/auth.controller.ts +++ b/apps/backend/src/app/auth/auth-feature/auth.controller.ts @@ -1,9 +1,4 @@ -import { - Body, - Controller, - NotImplementedException, - Post, -} from '@nestjs/common'; +import { Body, Controller, Post } from '@nestjs/common'; import { AuthControllerService } from './auth-controller.service'; import { ZodValidationPipe } from '../../../lib/nestjs/zod-validation/zod-validation.pipe'; import { ResetAuth, resetAuthParser } from '@my-monorepo/common'; @@ -21,6 +16,6 @@ export class AuthController { async postReset( @Body(new ZodValidationPipe(resetAuthParser)) resetData: ResetAuth, ): Promise { - throw new NotImplementedException(); + await this.authService.resetPassword(resetData); } } diff --git a/apps/backend/src/app/users/users-core/users-core.service.ts b/apps/backend/src/app/users/users-core/users-core.service.ts index 27e4d75..b967c88 100644 --- a/apps/backend/src/app/users/users-core/users-core.service.ts +++ b/apps/backend/src/app/users/users-core/users-core.service.ts @@ -16,4 +16,17 @@ export class UsersCoreService { ): Promise> { return await this.userRegistry.addUser(newUser); } + + async setHashPassword(userId: string, password: string): Promise { + const updateRes = await this.userRegistry.updatePasswordHash( + userId, + password, + ); + return updateRes.numUpdatedRows > 0; + } + + async getHashPassword(userId: string) { + const res = await this.userRegistry.getPasswordHash(userId); + return res?.hash ?? undefined; + } } diff --git a/apps/backend/src/app/users/users-core/users-registry.ts b/apps/backend/src/app/users/users-core/users-registry.ts index a093628..88b6c57 100644 --- a/apps/backend/src/app/users/users-core/users-registry.ts +++ b/apps/backend/src/app/users/users-core/users-registry.ts @@ -64,4 +64,19 @@ export class UsersRegistry { deletedAt: user.deleted_at ?? undefined, }); } + async updatePasswordHash(userId: string, hashedPassword: string) { + return this.database + .updateTable('users') + .set({ hash: hashedPassword }) + .where('users.id', '=', userId) + .executeTakeFirst(); + } + + async getPasswordHash(userId: string) { + return this.database + .selectFrom('users') + .select('users.hash') + .where('users.id', '=', userId) + .executeTakeFirst(); + } } diff --git a/apps/backend/src/lib/crypto.ts b/apps/backend/src/lib/crypto.ts index 03370d6..1442e68 100644 --- a/apps/backend/src/lib/crypto.ts +++ b/apps/backend/src/lib/crypto.ts @@ -1,5 +1,17 @@ import * as crypto from 'crypto'; +import * as argon2 from 'argon2'; -export function generateSecureToken(length = 32) { +export function generateSecureToken(length = 32): string { return crypto.randomBytes(length).toString('hex'); } + +export function hashPassword(password: string): Promise { + return argon2.hash(password); +} + +export function verifyPassword( + passwordHash: string, + clearTextPassword: string, +): Promise { + return argon2.verify(passwordHash, clearTextPassword); +} diff --git a/documentation/bruno-rest-api/LocalAPI/auth/reset password.bru b/documentation/bruno-rest-api/LocalAPI/auth/reset password.bru new file mode 100644 index 0000000..73ca6ff --- /dev/null +++ b/documentation/bruno-rest-api/LocalAPI/auth/reset password.bru @@ -0,0 +1,23 @@ +meta { + name: reset password + type: http + seq: 2 +} + +post { + url: {{BASE_URL}}/auth/reset + body: json + auth: inherit +} + +body:json { + { + "userId": "9", + "resetToken": "baeaff51ad72152db8b55006e95ca605a375f4d5143b764bbe0d24a6dca02baf", + "newPassword": "Password1234" + } +} + +settings { + encodeUrl: true +}