feat(02-01): AuthModule with Passport strategies, guards, and decorators

- Create LocalStrategy (username/password via argon2) and JwtStrategy (cookie extractor)
- Create JwtAuthGuard with @Public() decorator support for route opt-out
- Create RolesGuard checking SUPER_ADMIN/ADMIN/USER roles per D-12
- Create AuthService with validateUser, login (30-day httpOnly cookie), logout
- Create AuthController with POST /auth/login, POST /auth/logout, GET /auth/me
- Create LoginDto with class-validator decorators
- Create @Public, @Roles, @CurrentUser decorators
- Update main.ts with ValidationPipe, CORS credentials, cookie-parser
- Install cookie-parser for httpOnly JWT cookie support
This commit is contained in:
2026-06-18 13:24:59 +02:00
parent d0b36c8f22
commit 6190f3dd39
14 changed files with 358 additions and 1 deletions
+95
View File
@@ -0,0 +1,95 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as argon2 from 'argon2';
import { Response } from 'express';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
private configService: ConfigService,
) {}
/**
* 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,
};
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: '/',
});
}
}