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:
@@ -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: '/',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user