init import projet
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user