feat(02-03): password reset flow, force-change interceptor, MailModule

- MailModule with SMTP transport configured from ENV variables
- MailService for password reset and welcome emails (plain text, i18n)
- Password reset flow: request-reset (public), reset-password (token-based)
- Change password for logged-in users with current password verification
- Admin reset password endpoint (ADMIN/SUPER_ADMIN only, D-03)
- ForcePasswordChangeInterceptor blocks all routes except change-password,
  logout, me when mustChangePassword=true (D-06, Pitfall 5)
- Frontend: reset-password request page, token reset page, change-password page
- Forgot password link added to login page
- MailHog service added to docker-compose.dev.yml for dev email testing
- SMTP env vars added to docker-compose.yml (defaults to MailHog)
- Complete DE/EN i18n coverage for reset and change password flows
- SUS packages installed: @nestjs-modules/mailer, nodemailer, ldapts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 13:48:23 +02:00
parent eaaa9adfa5
commit ac617f4fe5
20 changed files with 4216 additions and 12 deletions
+155 -1
View File
@@ -1,16 +1,26 @@
import { Injectable } from '@nestjs/common';
import {
BadRequestException,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as argon2 from 'argon2';
import { randomUUID } from 'crypto';
import { Response } from 'express';
import { MailService } from '../mail/mail.service';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
private configService: ConfigService,
private mailService: MailService,
) {}
/**
@@ -59,6 +69,7 @@ export class AuthService {
username: user.username,
role: user.role,
tenantId: user.tenantId,
mustChangePassword: user.mustChangePassword,
};
const token = this.jwtService.sign(payload);
@@ -92,4 +103,147 @@ export class AuthService {
path: '/',
});
}
/**
* Request a password reset (D-03 self-service).
* T-02-12: Always returns success, even if email not found (prevent enumeration).
* T-02-13: Single-use token with 1-hour expiry.
*/
async requestPasswordReset(email: string): Promise<void> {
const user = await this.prisma.user.findUnique({
where: { email },
});
// Always return success to prevent email enumeration (T-02-12)
if (!user || !user.isActive) {
this.logger.log(
`Password reset requested for unknown/inactive email: ${email}`,
);
return;
}
// Generate a unique reset token
const token = randomUUID();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
// Create the reset token record
await this.prisma.passwordResetToken.create({
data: {
token,
userId: user.id,
expiresAt,
},
});
// Send the reset email (fire-and-forget, errors logged by MailService)
await this.mailService.sendPasswordResetEmail(email, token);
}
/**
* Reset password using a valid token (D-03 self-service).
* T-02-13: Validates token not expired, not used. Marks as used after success.
*/
async resetPassword(token: string, newPassword: string): Promise<void> {
const resetToken = await this.prisma.passwordResetToken.findUnique({
where: { token },
include: { user: true },
});
if (!resetToken) {
throw new BadRequestException('Invalid or expired reset token');
}
// Check if token has already been used
if (resetToken.usedAt) {
throw new BadRequestException('Reset token has already been used');
}
// Check if token has expired
if (resetToken.expiresAt < new Date()) {
throw new BadRequestException('Reset token has expired');
}
// Hash the new password and update user
const passwordHash = await argon2.hash(newPassword);
await this.prisma.user.update({
where: { id: resetToken.userId },
data: {
passwordHash,
mustChangePassword: false,
},
});
// Mark token as used (T-02-13)
await this.prisma.passwordResetToken.update({
where: { id: resetToken.id },
data: { usedAt: new Date() },
});
this.logger.log(`Password reset completed for user ${resetToken.userId}`);
}
/**
* Change password for the currently logged-in user.
* Verifies current password before allowing change.
*/
async changePassword(
userId: string,
currentPassword: string,
newPassword: string,
): Promise<void> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user || !user.passwordHash) {
throw new UnauthorizedException('User not found or has no local password');
}
// Verify current password
const isValid = await argon2.verify(user.passwordHash, currentPassword);
if (!isValid) {
throw new UnauthorizedException('Current password is incorrect');
}
// Hash new password and update
const passwordHash = await argon2.hash(newPassword);
await this.prisma.user.update({
where: { id: userId },
data: {
passwordHash,
mustChangePassword: false,
},
});
this.logger.log(`Password changed for user ${userId}`);
}
/**
* Admin reset of a user's password (D-03 admin reset).
* T-02-15: Only ADMIN/SUPER_ADMIN via RolesGuard.
*/
async adminResetPassword(
userId: string,
newPassword: string,
mustChangePassword: boolean = true,
): Promise<void> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new BadRequestException('User not found');
}
const passwordHash = await argon2.hash(newPassword);
await this.prisma.user.update({
where: { id: userId },
data: {
passwordHash,
mustChangePassword,
},
});
this.logger.log(`Admin reset password for user ${userId}`);
}
}