From 6190f3dd3943f3d9b5dc113b1f973819e9cf47ea Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 18 Jun 2026 13:24:59 +0200 Subject: [PATCH] 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 --- apps/api/package.json | 2 + apps/api/src/auth/auth.controller.ts | 54 +++++++++++ apps/api/src/auth/auth.module.ts | 25 +++++ apps/api/src/auth/auth.service.ts | 95 +++++++++++++++++++ .../auth/decorators/current-user.decorator.ts | 8 ++ .../src/auth/decorators/public.decorator.ts | 4 + .../src/auth/decorators/roles.decorator.ts | 5 + apps/api/src/auth/dto/login.dto.ts | 11 +++ apps/api/src/auth/guards/jwt-auth.guard.ts | 22 +++++ apps/api/src/auth/guards/roles.guard.ts | 24 +++++ apps/api/src/auth/strategies/jwt.strategy.ts | 35 +++++++ .../api/src/auth/strategies/local.strategy.ts | 20 ++++ apps/api/src/main.ts | 25 ++++- pnpm-lock.yaml | 29 ++++++ 14 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/auth/auth.controller.ts create mode 100644 apps/api/src/auth/auth.module.ts create mode 100644 apps/api/src/auth/auth.service.ts create mode 100644 apps/api/src/auth/decorators/current-user.decorator.ts create mode 100644 apps/api/src/auth/decorators/public.decorator.ts create mode 100644 apps/api/src/auth/decorators/roles.decorator.ts create mode 100644 apps/api/src/auth/dto/login.dto.ts create mode 100644 apps/api/src/auth/guards/jwt-auth.guard.ts create mode 100644 apps/api/src/auth/guards/roles.guard.ts create mode 100644 apps/api/src/auth/strategies/jwt.strategy.ts create mode 100644 apps/api/src/auth/strategies/local.strategy.ts diff --git a/apps/api/package.json b/apps/api/package.json index a6cc268..439f19c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..189c95f --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -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; + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..3a3144b --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -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('JWT_SECRET'), + signOptions: { expiresIn: '30d' }, + }), + inject: [ConfigService], + }), + ], + controllers: [AuthController], + providers: [AuthService, LocalStrategy, JwtStrategy], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..268bfa0 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -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 { + 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: '/', + }); + } +} diff --git a/apps/api/src/auth/decorators/current-user.decorator.ts b/apps/api/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..7919497 --- /dev/null +++ b/apps/api/src/auth/decorators/current-user.decorator.ts @@ -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; + }, +); diff --git a/apps/api/src/auth/decorators/public.decorator.ts b/apps/api/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/apps/api/src/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/api/src/auth/decorators/roles.decorator.ts b/apps/api/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..0d0c0d7 --- /dev/null +++ b/apps/api/src/auth/decorators/roles.decorator.ts @@ -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); diff --git a/apps/api/src/auth/dto/login.dto.ts b/apps/api/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..e7af3e1 --- /dev/null +++ b/apps/api/src/auth/dto/login.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class LoginDto { + @IsString() + @IsNotEmpty() + username!: string; + + @IsString() + @IsNotEmpty() + password!: string; +} diff --git a/apps/api/src/auth/guards/jwt-auth.guard.ts b/apps/api/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..4a7fb3b --- /dev/null +++ b/apps/api/src/auth/guards/jwt-auth.guard.ts @@ -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(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + return super.canActivate(context); + } +} diff --git a/apps/api/src/auth/guards/roles.guard.ts b/apps/api/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..c3e3b85 --- /dev/null +++ b/apps/api/src/auth/guards/roles.guard.ts @@ -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(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); + } +} diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..20b5d93 --- /dev/null +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -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('JWT_SECRET', 'fallback-secret'), + }); + } + + async validate(payload: any) { + return { + id: payload.sub, + username: payload.username, + role: payload.role, + tenantId: payload.tenantId, + }; + } +} diff --git a/apps/api/src/auth/strategies/local.strategy.ts b/apps/api/src/auth/strategies/local.strategy.ts new file mode 100644 index 0000000..8c537b7 --- /dev/null +++ b/apps/api/src/auth/strategies/local.strategy.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 4eb42b4..62743b2 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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( + 'CORS_ORIGIN', + 'http://localhost:3000', + ); + app.enableCors({ + origin: corsOrigin, + credentials: true, + }); await app.listen(3001); console.log('Tessera API running on port 3001'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de12f25..2691ed1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: class-validator: specifier: ^0.15.1 version: 0.15.1 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 passport: specifier: ^0.7.0 version: 0.7.0 @@ -72,6 +75,9 @@ importers: '@nestjs/cli': specifier: ^11.0.0 version: 11.0.23(@swc/core@1.15.41)(@types/node@22.19.21) + '@types/cookie-parser': + specifier: ^1.4.10 + version: 1.4.10(@types/express@5.0.6) '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -1033,6 +1039,11 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie-parser@1.4.10': + resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} + peerDependencies: + '@types/express': '*' + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -1396,6 +1407,13 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -3351,6 +3369,10 @@ snapshots: dependencies: '@types/node': 22.19.21 + '@types/cookie-parser@1.4.10(@types/express@5.0.6)': + dependencies: + '@types/express': 5.0.6 + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -3754,6 +3776,13 @@ snapshots: content-type@2.0.0: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {}