add password reset

This commit is contained in:
Paul Coral
2025-08-11 23:15:43 +02:00
parent da3a9d6880
commit 1f0a27a2e1
8 changed files with 123 additions and 14 deletions

View File

@@ -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<boolean> {
const res = await this.authRegistry.checkAndInvalidateResetToken(resetAuth);
return res > 0;
}
}

View File

@@ -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<bigint> {
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;
}
}

View File

@@ -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),
);
}
}

View File

@@ -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<void> {
throw new NotImplementedException();
await this.authService.resetPassword(resetData);
}
}

View File

@@ -16,4 +16,17 @@ export class UsersCoreService {
): Promise<Result<User, UserEmailAlreadyExistError>> {
return await this.userRegistry.addUser(newUser);
}
async setHashPassword(userId: string, password: string): Promise<boolean> {
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;
}
}

View File

@@ -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();
}
}

View File

@@ -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<string> {
return argon2.hash(password);
}
export function verifyPassword(
passwordHash: string,
clearTextPassword: string,
): Promise<boolean> {
return argon2.verify(passwordHash, clearTextPassword);
}

View File

@@ -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
}