Finaliza auth de rotas
This commit is contained in:
@@ -9,9 +9,9 @@
|
|||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "NODE_ENV=development nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "NODE_ENV=development nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "NODE_ENV=production node dist/main",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
"@types/express": "5.0.0",
|
"@types/express": "5.0.0",
|
||||||
"@types/jest": "30.0.0",
|
"@types/jest": "30.0.0",
|
||||||
"@types/node": "22.10.7",
|
"@types/node": "22.10.7",
|
||||||
|
"@types/passport-jwt": "4.0.1",
|
||||||
"@types/supertest": "6.0.2",
|
"@types/supertest": "6.0.2",
|
||||||
"eslint": "9.18.0",
|
"eslint": "9.18.0",
|
||||||
"eslint-config-prettier": "10.0.1",
|
"eslint-config-prettier": "10.0.1",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
|
||||||
|
jest.mock('axios');
|
||||||
|
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let service: AuthService;
|
let service: AuthService;
|
||||||
@@ -10,9 +17,160 @@ describe('AuthService', () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<AuthService>(AuthService);
|
service = module.get<AuthService>(AuthService);
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getUrl', () => {
|
||||||
|
it('should return development URL when NODE_ENV is development', () => {
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
expect(service['getUrl']()).toBe('https://auth.clipperia.com.br');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return production URL when NODE_ENV is not development', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
expect(service['getUrl']()).toBe('http://keycloak:8080');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
const loginDto: LoginDto = {
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'testpass',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTokenResponse = {
|
||||||
|
access_token: 'mock-access-token',
|
||||||
|
refresh_token: 'mock-refresh-token',
|
||||||
|
expires_in: 300,
|
||||||
|
refresh_expires_in: 1800,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should login successfully', async () => {
|
||||||
|
const mockPost = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ data: mockTokenResponse });
|
||||||
|
mockedAxios.create.mockReturnValueOnce({
|
||||||
|
post: mockPost,
|
||||||
|
} as unknown as AxiosInstance);
|
||||||
|
|
||||||
|
const result = await service.login(loginDto);
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/token',
|
||||||
|
expect.any(URLSearchParams),
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockTokenResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException when login fails', async () => {
|
||||||
|
const mockError = {
|
||||||
|
isAxiosError: true,
|
||||||
|
response: {
|
||||||
|
status: 401,
|
||||||
|
data: { error: 'invalid_grant' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPost = jest.fn().mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
|
mockedAxios.create.mockReturnValueOnce({
|
||||||
|
post: mockPost,
|
||||||
|
} as unknown as AxiosInstance);
|
||||||
|
|
||||||
|
await expect(service.login(loginDto)).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
const refreshToken = 'mock-refresh-token';
|
||||||
|
|
||||||
|
it('should logout successfully', async () => {
|
||||||
|
const mockPost = jest.fn().mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
mockedAxios.create.mockReturnValueOnce({
|
||||||
|
post: mockPost,
|
||||||
|
} as unknown as AxiosInstance);
|
||||||
|
|
||||||
|
await service.logout(refreshToken);
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/logout',
|
||||||
|
expect.any(URLSearchParams),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException when logout fails', async () => {
|
||||||
|
const mockError = {
|
||||||
|
isAxiosError: true,
|
||||||
|
response: {
|
||||||
|
status: 400,
|
||||||
|
data: { error: 'invalid_token' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPost = jest.fn().mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
|
mockedAxios.create.mockReturnValueOnce({
|
||||||
|
post: mockPost,
|
||||||
|
} as unknown as AxiosInstance);
|
||||||
|
|
||||||
|
await expect(service.logout(refreshToken)).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshToken', () => {
|
||||||
|
const refreshToken = 'mock-refresh-token';
|
||||||
|
const mockTokenResponse = {
|
||||||
|
access_token: 'new-access-token',
|
||||||
|
refresh_token: 'new-refresh-token',
|
||||||
|
expires_in: 300,
|
||||||
|
refresh_expires_in: 1800,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should refresh token successfully', async () => {
|
||||||
|
const mockPost = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ data: mockTokenResponse });
|
||||||
|
|
||||||
|
mockedAxios.create.mockReturnValueOnce({
|
||||||
|
post: mockPost,
|
||||||
|
} as unknown as AxiosInstance);
|
||||||
|
|
||||||
|
const result = await service.refreshToken(refreshToken);
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/token',
|
||||||
|
expect.any(URLSearchParams),
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockTokenResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException when refresh token is invalid', async () => {
|
||||||
|
const mockError = {
|
||||||
|
isAxiosError: true,
|
||||||
|
response: {
|
||||||
|
status: 400,
|
||||||
|
data: { error: 'invalid_grant' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPost = jest.fn().mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
|
mockedAxios.create.mockReturnValueOnce({
|
||||||
|
post: mockPost,
|
||||||
|
} as unknown as AxiosInstance);
|
||||||
|
|
||||||
|
await expect(service.refreshToken(refreshToken)).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import LoginResponseDto from './dto/loginResponse.dto';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly keycloakUrl =
|
private readonly keycloakUrl =
|
||||||
'https://auth.clipperia.com.br/realms/clipperia/protocol/openid-connect';
|
this.getUrl() + '/realms/clipperia/protocol/openid-connect';
|
||||||
|
|
||||||
private readonly clientId = 'account';
|
private readonly clientId = 'account';
|
||||||
|
|
||||||
private readonly keycloakApi = axios.create({
|
private readonly keycloakApi = axios.create({
|
||||||
@@ -19,6 +20,12 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
getUrl() {
|
||||||
|
return process.env.NODE_ENV === 'development'
|
||||||
|
? 'https://auth.clipperia.com.br'
|
||||||
|
: 'http://keycloak:8080';
|
||||||
|
}
|
||||||
|
|
||||||
async login(loginDto: LoginDto) {
|
async login(loginDto: LoginDto) {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class LoginDto {
|
export class LoginDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsOptional()
|
||||||
username: string;
|
username: string;
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsEmail()
|
|
||||||
@IsNotEmpty()
|
|
||||||
email: string;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { JwtPayload } from './keycloak.strategy';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KeycloakAuthGuard extends AuthGuard('jwt') {
|
export class KeycloakAuthGuard extends AuthGuard('jwt') {
|
||||||
handleRequest(err: unknown, user: JwtPayload): JwtPayload {
|
handleRequest<TUser = JwtPayload>(err: any, user: JwtPayload): TUser {
|
||||||
if (err || !user) {
|
if (err || !user) {
|
||||||
if (err instanceof UnauthorizedException) {
|
if (err instanceof UnauthorizedException) {
|
||||||
throw err;
|
throw err;
|
||||||
@@ -13,6 +13,6 @@ export class KeycloakAuthGuard extends AuthGuard('jwt') {
|
|||||||
throw new UnauthorizedException('Usuário não autenticado');
|
throw new UnauthorizedException('Usuário não autenticado');
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return user as unknown as TUser;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import * as jwksRsa from 'jwks-rsa';
|
import * as jwksRsa from 'jwks-rsa';
|
||||||
@@ -32,23 +32,31 @@ export interface JwtPayload {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class KeycloakJwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class KeycloakJwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
const baseUrl =
|
||||||
|
process.env.NODE_ENV === 'production'
|
||||||
|
? 'http://keycloak:8080'
|
||||||
|
: 'https://auth.clipperia.com.br';
|
||||||
|
|
||||||
super({
|
super({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
secretOrKeyProvider: jwksRsa.passportJwtSecret({
|
secretOrKeyProvider: jwksRsa.passportJwtSecret({
|
||||||
cache: true,
|
cache: true,
|
||||||
rateLimit: true,
|
rateLimit: true,
|
||||||
jwksRequestsPerMinute: 5,
|
jwksRequestsPerMinute: 5,
|
||||||
jwksUri:
|
jwksUri: `${baseUrl}/realms/clipperia/protocol/openid-connect/certs`,
|
||||||
'https://auth.clipperia.com.br/realms/clipperia/protocol/openid-connect/certs',
|
|
||||||
}),
|
}),
|
||||||
algorithms: ['RS256'],
|
algorithms: ['RS256'],
|
||||||
audience: 'account',
|
issuer: `${baseUrl}/realms/clipperia`,
|
||||||
issuer: 'https://auth.clipperia.com.br/realms/clipperia',
|
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
validate(payload: JwtPayload): JwtPayload {
|
validate(payload: JwtPayload): JwtPayload {
|
||||||
|
if (payload.exp < Date.now() / 1000) {
|
||||||
|
throw new UnauthorizedException('Token expirado');
|
||||||
|
}
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user