mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +00:00
feat(r0.10): apps/api — auth fermée par défaut, matrice en base, démo-login ADR-002, seed
- packages/shared : rôles/catégories, schémas Zod, contrat d'API ; pnpm contract → docs/openapi.json committée (règle d'or ADR-001) - apps/api : NestJS 11 + Prisma 6, migration r0_identity (Role/Permission/User) ; guard JWT global + @Public() ; PermissionsGuard (@RequirePermission, matrice relue en base, cache 60 s) ; FileStorage (seul import MinIO) ; /health - démo-login ADR-002 : module conditionnel DEMO_MODE (404 sinon, testé e2e), double verrou production, refus des comptes isDemo=false - seed idempotent : 7 rôles, matrice complète (70 lignes), 7 comptes démo - 19 tests Jest (unit + e2e) ; smoke test sur build de prod Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
17
apps/api/src/auth/auth.controller.ts
Normal file
17
apps/api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
||||
import { LoginRequestSchema, type LoginRequest } from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { AuthService } from './auth.service';
|
||||
import { Public } from './public.decorator';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) {
|
||||
return this.authService.login(body.email, body.password);
|
||||
}
|
||||
}
|
||||
26
apps/api/src/auth/auth.module.ts
Normal file
26
apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule, type JwtSignOptions } from '@nestjs/jwt';
|
||||
import { loadEnv } from '../config/env';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.registerAsync({
|
||||
global: true,
|
||||
useFactory: () => {
|
||||
const env = loadEnv();
|
||||
return {
|
||||
secret: env.JWT_SECRET,
|
||||
signOptions: {
|
||||
expiresIn: env.JWT_EXPIRES_IN as JwtSignOptions['expiresIn'],
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
104
apps/api/src/auth/auth.service.ts
Normal file
104
apps/api/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type {
|
||||
AuthResponse,
|
||||
DemoAccountsResponse,
|
||||
RoleName,
|
||||
} from '@siop/shared';
|
||||
import * as argon2 from 'argon2';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
type UserWithRole = {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
isDemo: boolean;
|
||||
role: { id: string; name: string };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async login(email: string, password: string): Promise<AuthResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
include: { role: true },
|
||||
});
|
||||
// argon2.verify même sur compte inconnu = temps de réponse homogène
|
||||
const valid =
|
||||
user?.passwordHash != null &&
|
||||
(await argon2.verify(user.passwordHash, password));
|
||||
if (!user || !user.isActive || !valid) {
|
||||
throw new UnauthorizedException('Identifiants invalides');
|
||||
}
|
||||
return this.issueToken(user);
|
||||
}
|
||||
|
||||
/** ADR-002 — liste publique en DEMO_MODE, jamais de secret. */
|
||||
async listDemoAccounts(): Promise<DemoAccountsResponse> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { isDemo: true, isActive: true },
|
||||
include: { role: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return {
|
||||
accounts: users.map((u) => ({
|
||||
id: u.id,
|
||||
displayName: u.displayName,
|
||||
roleName: u.role.name as RoleName,
|
||||
initials: this.initials(u.displayName),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** ADR-002 — refuse tout compte isDemo=false, quoi qu'il arrive. */
|
||||
async demoLogin(userId: string): Promise<AuthResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { role: true },
|
||||
});
|
||||
if (!user || !user.isActive) throw new NotFoundException('Compte inconnu');
|
||||
if (!user.isDemo) {
|
||||
throw new ForbiddenException(
|
||||
'Ce compte n’est pas un compte de démonstration',
|
||||
);
|
||||
}
|
||||
return this.issueToken(user);
|
||||
}
|
||||
|
||||
private async issueToken(user: UserWithRole): Promise<AuthResponse> {
|
||||
// Identité seule — les droits restent en base (invariant R0)
|
||||
const accessToken = await this.jwtService.signAsync({
|
||||
sub: user.id,
|
||||
roleId: user.role.id,
|
||||
});
|
||||
return {
|
||||
accessToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: { id: user.role.id, name: user.role.name as RoleName },
|
||||
isDemo: user.isDemo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private initials(displayName: string): string {
|
||||
return displayName
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]!.toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
14
apps/api/src/auth/current-user.decorator.ts
Normal file
14
apps/api/src/auth/current-user.decorator.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/** Identité portée par le JWT — jamais de droits (invariant R0) :
|
||||
* la matrice est relue en base par PermissionsGuard. */
|
||||
export interface AuthenticatedUser {
|
||||
userId: string;
|
||||
roleId: string;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, ctx: ExecutionContext): AuthenticatedUser => {
|
||||
return ctx.switchToHttp().getRequest().user;
|
||||
},
|
||||
);
|
||||
27
apps/api/src/auth/demo/demo-auth.controller.ts
Normal file
27
apps/api/src/auth/demo/demo-auth.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
|
||||
import { DemoLoginRequestSchema, type DemoLoginRequest } from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../../common/zod-validation.pipe';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { Public } from '../public.decorator';
|
||||
|
||||
/** ADR-002 — ces routes n'existent que si DemoAuthModule est enregistré
|
||||
* (DEMO_MODE=true) ; sinon : 404, vérifié par test e2e. */
|
||||
@Controller('auth')
|
||||
export class DemoAuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Get('demo-accounts')
|
||||
listDemoAccounts() {
|
||||
return this.authService.listDemoAccounts();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('demo-login')
|
||||
@HttpCode(200)
|
||||
demoLogin(
|
||||
@Body(new ZodValidationPipe(DemoLoginRequestSchema)) body: DemoLoginRequest,
|
||||
) {
|
||||
return this.authService.demoLogin(body.userId);
|
||||
}
|
||||
}
|
||||
27
apps/api/src/auth/demo/demo-auth.module.ts
Normal file
27
apps/api/src/auth/demo/demo-auth.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { assertDemoModeAllowed } from '../../config/env';
|
||||
import { AuthModule } from '../auth.module';
|
||||
import { DemoAuthController } from './demo-auth.controller';
|
||||
|
||||
/**
|
||||
* ADR-002 : module enregistré UNIQUEMENT quand DEMO_MODE=true
|
||||
* (voir AppModule.forRoot). Double verrou : refuse de démarrer en production
|
||||
* sans DEMO_MODE_I_KNOW=true.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [DemoAuthController],
|
||||
})
|
||||
export class DemoAuthModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(DemoAuthModule.name);
|
||||
|
||||
constructor() {
|
||||
assertDemoModeAllowed();
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.logger.warn(
|
||||
'DEMO_MODE actif — /auth/demo-accounts et /auth/demo-login sont exposées.',
|
||||
);
|
||||
}
|
||||
}
|
||||
46
apps/api/src/auth/jwt-auth.guard.ts
Normal file
46
apps/api/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { Request } from 'express';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
|
||||
/** Guard global : API fermée par défaut, ouverture explicite via @Public(). */
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const [scheme, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
if (scheme !== 'Bearer' || !token) {
|
||||
throw new UnauthorizedException('Jeton manquant');
|
||||
}
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<{
|
||||
sub: string;
|
||||
roleId: string;
|
||||
}>(token);
|
||||
(request as Request & { user: unknown }).user = {
|
||||
userId: payload.sub,
|
||||
roleId: payload.roleId,
|
||||
};
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Jeton invalide ou expiré');
|
||||
}
|
||||
}
|
||||
}
|
||||
7
apps/api/src/auth/public.decorator.ts
Normal file
7
apps/api/src/auth/public.decorator.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
|
||||
/** L'API est fermée par défaut (guard JWT global) : toute route publique
|
||||
* doit l'être EXPLICITEMENT via ce décorateur. */
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
Reference in New Issue
Block a user