0fba45d2c2
- Add avatarPath String? column to User model (migration: add_user_avatar)
- POST /users/me/avatar: 2MB limit, image/png/jpeg/webp allowlist, writes to user-files/avatars/{userId}.{ext}
- GET /users/me/avatar: streams avatar with Cache-Control: no-store
- AuthService.getMe(): returns isLocalUser + hasAvatar without leaking passwordHash/ldapDn
- AuthController GET /auth/me: now returns enriched profile via getMe()
296 lines
7.8 KiB
TypeScript
296 lines
7.8 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}`);
|
|
}
|
|
|
|
/**
|
|
* Return enriched profile for the currently authenticated user.
|
|
* T-gbh-03: Only public fields + isLocalUser/hasAvatar returned — never
|
|
* passwordHash or ldapDn.
|
|
*/
|
|
async getMe(userId: string) {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
displayName: true,
|
|
role: true,
|
|
tenantId: true,
|
|
mustChangePassword: true,
|
|
passwordHash: true,
|
|
ldapDn: true,
|
|
avatarPath: true,
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const { passwordHash, ldapDn, avatarPath, ...publicFields } = user;
|
|
|
|
return {
|
|
...publicFields,
|
|
isLocalUser: !!passwordHash && !ldapDn,
|
|
hasAvatar: !!avatarPath,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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}`);
|
|
}
|
|
}
|