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:
@@ -20,6 +20,7 @@
|
|||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,10 +1,33 @@
|
|||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
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);
|
await app.listen(3001);
|
||||||
console.log('Tessera API running on port 3001');
|
console.log('Tessera API running on port 3001');
|
||||||
|
|||||||
Generated
+29
@@ -53,6 +53,9 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.15.1
|
specifier: ^0.15.1
|
||||||
version: 0.15.1
|
version: 0.15.1
|
||||||
|
cookie-parser:
|
||||||
|
specifier: ^1.4.7
|
||||||
|
version: 1.4.7
|
||||||
passport:
|
passport:
|
||||||
specifier: ^0.7.0
|
specifier: ^0.7.0
|
||||||
version: 0.7.0
|
version: 0.7.0
|
||||||
@@ -72,6 +75,9 @@ importers:
|
|||||||
'@nestjs/cli':
|
'@nestjs/cli':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.0.23(@swc/core@1.15.41)(@types/node@22.19.21)
|
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':
|
'@types/express':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.6
|
version: 5.0.6
|
||||||
@@ -1033,6 +1039,11 @@ packages:
|
|||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
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':
|
'@types/eslint-scope@3.7.7':
|
||||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
||||||
|
|
||||||
@@ -1396,6 +1407,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
|
resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
|
||||||
engines: {node: '>=18'}
|
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:
|
cookie-signature@1.2.2:
|
||||||
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
||||||
engines: {node: '>=6.6.0'}
|
engines: {node: '>=6.6.0'}
|
||||||
@@ -3351,6 +3369,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.21
|
'@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':
|
'@types/eslint-scope@3.7.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/eslint': 9.6.1
|
'@types/eslint': 9.6.1
|
||||||
@@ -3754,6 +3776,13 @@ snapshots:
|
|||||||
|
|
||||||
content-type@2.0.0: {}
|
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-signature@1.2.2: {}
|
||||||
|
|
||||||
cookie@0.7.2: {}
|
cookie@0.7.2: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user