Files
tessera-ctl/apps/api/src/auth/auth.service.ts
T
schalli 5779f0f6c9
Tessera CI/CD / Lint & Type Check (push) Successful in 39s
Tessera CI/CD / Tests (push) Successful in 36s
Tessera CI/CD / Build & Publish Images (push) Successful in 23s
fix(api): reissue JWT with mustChangePassword=false after password change
After a successful password change the old cookie still contained
mustChangePassword=true, causing the middleware to redirect back to
/change-password. Now changePassword issues a fresh session cookie.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:09:36 +02:00

262 lines
7.0 KiB
TypeScript

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,
) {}
/**
* Validate user credentials. Uses unscoped Prisma (no tenant context)
* because login must work across all tenants.
*
* T-02-01: Returns null on any failure (never reveals which field is wrong).
* Pitfall 6: Checks isActive to prevent deactivated users from logging in.
*/
async validateUser(username: string, password: string): Promise<any> {
const user = await this.prisma.user.findUnique({
where: { username },
});
if (!user || !user.isActive) {
return null;
}
// LDAP users without local password cannot log in via local auth
if (!user.passwordHash) {
return null;
}
const isPasswordValid = await argon2.verify(user.passwordHash, password);
if (!isPasswordValid) {
return null;
}
// Update lastLoginAt
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
return user;
}
/**
* Issue JWT in httpOnly cookie and return user info.
* D-02: 30-day session.
* T-02-02: httpOnly + secure (prod) + sameSite=lax.
*/
async login(user: any, response: Response) {
const payload = {
sub: user.id,
username: user.username,
role: user.role,
tenantId: user.tenantId,
mustChangePassword: user.mustChangePassword,
};
const token = this.jwtService.sign(payload);
response.cookie('session', token, {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
path: '/',
});
return {
id: user.id,
username: user.username,
role: user.role,
displayName: user.displayName,
tenantId: user.tenantId,
mustChangePassword: user.mustChangePassword,
};
}
/**
* Clear the session cookie to log the user out.
*/
logout(response: Response) {
response.clearCookie('session', {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
sameSite: 'lax',
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,
response: Response,
): 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');
}
const isValid = await argon2.verify(user.passwordHash, currentPassword);
if (!isValid) {
throw new UnauthorizedException('Current password is incorrect');
}
const passwordHash = await argon2.hash(newPassword);
await this.prisma.user.update({
where: { id: userId },
data: { passwordHash, mustChangePassword: false },
});
const payload = {
sub: user.id,
username: user.username,
role: user.role,
tenantId: user.tenantId,
mustChangePassword: false,
};
const token = this.jwtService.sign(payload);
(response as any).cookie('session', token, {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000,
path: '/',
});
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}`);
}
}