diff --git a/package.json b/package.json index ef1d31e..875ab73 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@nestjs/common": "11.0.1", "@nestjs/config": "4.0.2", "@nestjs/core": "11.0.1", + "@nestjs/passport": "11.0.5", "@nestjs/platform-express": "11.0.1", "@prisma/client": "6.14.0", "axios": "1.12.0", @@ -30,6 +31,9 @@ "class-transformer": "0.5.1", "class-validator": "0.14.2", "dayjs": "1.11.13", + "jwks-rsa": "3.2.0", + "passport": "0.7.0", + "passport-jwt": "4.0.1", "reflect-metadata": "0.2.2", "rxjs": "7.8.1" }, diff --git a/src/app.module.ts b/src/app.module.ts index d9c6a4b..f4fef17 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,7 +12,7 @@ import { LoggerMiddleware } from './middleware/logger.middleware'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), + ConfigModule.forRoot(), PrismaModule, VideosModule, AuthModule, diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index b8219aa..358d9d1 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; +import { KeycloakJwtStrategy } from './keycloak.strategy'; @Module({ controllers: [AuthController], - providers: [AuthService], + providers: [AuthService, KeycloakJwtStrategy], exports: [AuthService], }) export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index a48cee0..ea1f26e 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -10,8 +10,7 @@ import LoginResponseDto from './dto/loginResponse.dto'; export class AuthService { private readonly keycloakUrl = 'https://auth.clipperia.com.br/realms/clipperia/protocol/openid-connect'; - private readonly clientId = 'usuarios'; - private readonly clientSecret = process.env.KEYCLOAK_CLIENT_SECRET; + private readonly clientId = 'account'; private readonly keycloakApi = axios.create({ baseURL: this.keycloakUrl, @@ -29,10 +28,6 @@ export class AuthService { password: loginDto.password, }); - if (this.clientSecret) { - params.append('client_secret', this.clientSecret); - } - const { data } = await this.keycloakApi.post( '/token', params, @@ -56,10 +51,6 @@ export class AuthService { refresh_token: refreshToken, }); - if (this.clientSecret) { - data.append('client_secret', this.clientSecret); - } - await this.keycloakApi.post('/logout', data); } catch (error) { if (isAxiosError(error)) { @@ -72,8 +63,6 @@ export class AuthService { } async refreshToken(refreshToken: string) { - console.log(refreshToken); - try { const params = new URLSearchParams({ client_id: this.clientId, @@ -81,10 +70,6 @@ export class AuthService { refresh_token: refreshToken, }); - if (this.clientSecret) { - params.append('client_secret', this.clientSecret); - } - const { data } = await this.keycloakApi.post( '/token', params, diff --git a/src/auth/keycloak-auth.guard.ts b/src/auth/keycloak-auth.guard.ts new file mode 100644 index 0000000..c256b68 --- /dev/null +++ b/src/auth/keycloak-auth.guard.ts @@ -0,0 +1,18 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import type { JwtPayload } from './keycloak.strategy'; + +@Injectable() +export class KeycloakAuthGuard extends AuthGuard('jwt') { + handleRequest(err: unknown, user: JwtPayload): JwtPayload { + if (err || !user) { + if (err instanceof UnauthorizedException) { + throw err; + } + + throw new UnauthorizedException('Usuário não autenticado'); + } + + return user; + } +} diff --git a/src/auth/keycloak.strategy.ts b/src/auth/keycloak.strategy.ts new file mode 100644 index 0000000..d32b9ec --- /dev/null +++ b/src/auth/keycloak.strategy.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import * as jwksRsa from 'jwks-rsa'; + +export type JwtAudience = string | string[] | undefined; +export interface JwtRealmAccess { + roles: string[]; +} +export interface JwtResourceAccessEntry { + roles: string[]; +} +export type JwtResourceAccess = + | Record + | undefined; +export interface JwtPayload { + sub: string; + email?: string; + preferred_username?: string; + given_name?: string; + family_name?: string; + scope?: string; + realm_access?: JwtRealmAccess; + resource_access?: JwtResourceAccess; + iat: number; + exp: number; + iss: string; + aud?: JwtAudience; + [claim: string]: unknown; +} + +@Injectable() +export class KeycloakJwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKeyProvider: jwksRsa.passportJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, + jwksUri: + 'https://auth.clipperia.com.br/realms/clipperia/protocol/openid-connect/certs', + }), + algorithms: ['RS256'], + audience: 'account', + issuer: 'https://auth.clipperia.com.br/realms/clipperia', + ignoreExpiration: false, + }); + } + + validate(payload: JwtPayload): JwtPayload { + return payload; + } +} diff --git a/src/videos/videos.controller.ts b/src/videos/videos.controller.ts index f6a1bfa..6ae92b9 100644 --- a/src/videos/videos.controller.ts +++ b/src/videos/videos.controller.ts @@ -6,6 +6,7 @@ import { Delete, Body, Query, + UseGuards, } from '@nestjs/common'; import { videos, Prisma, video_situation } from 'generated/prisma'; @@ -13,12 +14,14 @@ import { VideosService } from './videos.service'; import { VideoResponseDto } from './dto/video-response.dto'; import { PaginatedQueryDto, PaginatedResponse } from '../shared/dto/paginated'; import { EBooleanPipe } from '../shared/pipe'; +import { KeycloakAuthGuard } from '../auth/keycloak-auth.guard'; @Controller('videos') export class VideosController { constructor(private readonly videosService: VideosService) {} @Get() + @UseGuards(KeycloakAuthGuard) async list( @Query() query: PaginatedQueryDto, @Query('situation') situation?: video_situation,