init import projet

This commit is contained in:
2026-05-03 21:58:59 +02:00
parent f3756fdf8d
commit 8d3df9bbbb
179 changed files with 37694 additions and 132 deletions
+33
View File
@@ -0,0 +1,33 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import type { AuthenticatedRequest } from './jwt-auth.guard';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
@Post('refresh')
refresh(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refresh(refreshTokenDto);
}
@Post('logout')
@UseGuards(JwtAuthGuard)
logout(@Req() request: AuthenticatedRequest) {
return this.authService.logout(request.user!.sub);
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@Req() request: AuthenticatedRequest) {
return this.authService.me(request.user!.sub);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import type { StringValue } from 'ms';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { RolesGuard } from './roles.guard';
@Module({
imports: [
ConfigModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') ?? 'change_me_jwt_secret',
signOptions: {
expiresIn:
(configService.get<string>('JWT_EXPIRES_IN') as StringValue) ?? '1d',
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard, RolesGuard],
exports: [AuthService, JwtAuthGuard, RolesGuard, JwtModule],
})
export class AuthModule {}
+179
View File
@@ -0,0 +1,179 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import type { StringValue } from 'ms';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@Injectable()
export class AuthService {
constructor(
private readonly prismaService: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
private async issueTokens(user: {
id: string;
username: string;
role: string;
displayName: string | null;
profileImageUrl: string | null;
workspaceId: string | null;
createdAt: Date;
}) {
const membership = await this.prismaService.projectMembership.findUnique({
where: { userId: user.id },
select: { projectId: true },
});
const currentProjectId = membership?.projectId ?? null;
const accessToken = await this.jwtService.signAsync({
sub: user.id,
username: user.username,
role: user.role,
workspaceId: user.workspaceId,
projectId: currentProjectId,
});
const refreshToken = await this.jwtService.signAsync(
{ sub: user.id, type: 'refresh' },
{
secret:
this.configService.get<string>('REFRESH_TOKEN_SECRET') ??
'change_me_refresh_secret',
expiresIn:
(this.configService.get<string>(
'REFRESH_TOKEN_EXPIRES_IN',
) as StringValue) ?? '30d',
},
);
const refreshTokenHash = await bcrypt.hash(refreshToken, 12);
await this.prismaService.user.update({
where: { id: user.id },
data: { refreshTokenHash },
});
return {
accessToken,
refreshToken,
user: {
id: user.id,
username: user.username,
displayName: user.displayName,
profileImageUrl: user.profileImageUrl,
workspaceId: user.workspaceId,
currentProjectId,
role: user.role,
createdAt: user.createdAt,
},
};
}
async login(loginDto: LoginDto) {
const normalizedUsername = loginDto.username.trim();
const user = await this.prismaService.user.findUnique({
where: { username: normalizedUsername },
});
const caseInsensitiveUser = user ?? (await this.prismaService.user.findFirst({
where: {
username: {
equals: normalizedUsername,
mode: 'insensitive',
},
},
}));
if (!caseInsensitiveUser) {
throw new UnauthorizedException('Invalid credentials');
}
const passwordMatches = await bcrypt.compare(
loginDto.password,
caseInsensitiveUser.passwordHash,
);
if (!passwordMatches) {
throw new UnauthorizedException('Invalid credentials');
}
return this.issueTokens(caseInsensitiveUser);
}
async refresh(refreshTokenDto: RefreshTokenDto) {
const refreshSecret =
this.configService.get<string>('REFRESH_TOKEN_SECRET') ??
'change_me_refresh_secret';
try {
const payload = await this.jwtService.verifyAsync(refreshTokenDto.refreshToken, {
secret: refreshSecret,
});
if (payload.type !== 'refresh') {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await this.prismaService.user.findUnique({
where: { id: payload.sub as string },
});
if (!user || !user.refreshTokenHash) {
throw new UnauthorizedException('Invalid refresh token');
}
const isRefreshTokenValid = await bcrypt.compare(
refreshTokenDto.refreshToken,
user.refreshTokenHash,
);
if (!isRefreshTokenValid) {
throw new UnauthorizedException('Invalid refresh token');
}
return this.issueTokens(user);
} catch {
throw new UnauthorizedException('Invalid or expired refresh token');
}
}
async logout(userId: string) {
await this.prismaService.user.updateMany({
where: { id: userId },
data: { refreshTokenHash: null },
});
return { loggedOut: true };
}
async me(userId: string) {
const user = await this.prismaService.user.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException('User not found');
}
return {
id: user.id,
username: user.username,
displayName: user.displayName,
profileImageUrl: user.profileImageUrl,
workspaceId: user.workspaceId,
currentProjectId: (
await this.prismaService.projectMembership.findUnique({
where: { userId: user.id },
select: { projectId: true },
})
)?.projectId ?? null,
role: user.role,
createdAt: user.createdAt,
};
}
}
+13
View File
@@ -0,0 +1,13 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class LoginDto {
@IsString()
@MinLength(3)
@MaxLength(40)
username!: string;
@IsString()
@MinLength(8)
@MaxLength(128)
password!: string;
}
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class RefreshTokenDto {
@IsString()
@MinLength(10)
refreshToken!: string;
}
+57
View File
@@ -0,0 +1,57 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { UserRole } from '@prisma/client';
export type AuthenticatedRequest = Request & {
user?: {
sub: string;
username: string;
role: UserRole;
workspaceId?: string | null;
projectId?: string | null;
};
};
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing bearer token');
}
const token = authHeader.slice('Bearer '.length);
try {
const payload = await this.jwtService.verifyAsync(token, {
secret:
this.configService.get<string>('JWT_SECRET') ?? 'change_me_jwt_secret',
});
request.user = {
sub: payload.sub as string,
username: payload.username as string,
role: payload.role as UserRole,
workspaceId: (payload.workspaceId as string | null | undefined) ?? null,
projectId: (payload.projectId as string | null | undefined) ?? null,
};
return true;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
}
+5
View File
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
+35
View File
@@ -0,0 +1,35 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '@prisma/client';
import { AuthenticatedRequest } from './jwt-auth.guard';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const userRole = request.user?.role;
if (!userRole || !requiredRoles.includes(userRole)) {
throw new ForbiddenException('Insufficient role');
}
return true;
}
}