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
+2
View File
@@ -20,6 +20,7 @@
"argon2": "^0.44.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
@@ -28,6 +29,7 @@
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
+54
View File
@@ -0,0 +1,54 @@
import {
Controller,
Get,
HttpCode,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator';
import { Public } from './decorators/public.decorator';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
/**
* POST /auth/login
* Validates credentials via Passport local strategy, then issues JWT cookie.
*/
@Public()
@UseGuards(AuthGuard('local'))
@Post('login')
@HttpCode(200)
async login(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
return this.authService.login(req.user, res);
}
/**
* POST /auth/logout
* Clears the session cookie.
*/
@Post('logout')
@HttpCode(200)
logout(@Res({ passthrough: true }) res: Response) {
this.authService.logout(res);
return { message: 'Logged out' };
}
/**
* GET /auth/me
* Returns the current user from JWT (session check).
*/
@Get('me')
me(@CurrentUser() user: any) {
return user;
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '30d' },
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
+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: '/',
});
}
}
@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
+11
View File
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@IsString()
@IsNotEmpty()
username!: string;
@IsString()
@IsNotEmpty()
password!: string;
}
@@ -0,0 +1,22 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '@prisma/client';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) {
return false;
}
return requiredRoles.includes(user.role);
}
}
@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { Request } from 'express';
/**
* Custom extractor that reads JWT from the httpOnly "session" cookie.
*/
function cookieExtractor(req: Request): string | null {
if (req && req.cookies) {
return req.cookies['session'] || null;
}
return null;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: cookieExtractor,
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET', 'fallback-secret'),
});
}
async validate(payload: any) {
return {
id: payload.sub,
username: payload.username,
role: payload.role,
tenantId: payload.tenantId,
};
}
}
@@ -0,0 +1,20 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({ usernameField: 'username' });
}
async validate(username: string, password: string): Promise<any> {
const user = await this.authService.validateUser(username, password);
if (!user) {
// T-02-01: Generic error message - never reveal whether username or password is wrong
throw new UnauthorizedException('Invalid credentials');
}
return user;
}
}
+24 -1
View File
@@ -1,10 +1,33 @@
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
app.enableCors({ origin: true });
// Cookie parser for JWT httpOnly cookies
app.use(cookieParser());
// Global validation pipe with whitelist and transform
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);
// CORS with credentials for cross-origin cookie support (Pitfall 4)
const corsOrigin = configService.get<string>(
'CORS_ORIGIN',
'http://localhost:3000',
);
app.enableCors({
origin: corsOrigin,
credentials: true,
});
await app.listen(3001);
console.log('Tessera API running on port 3001');