FEAT: Implementa módulo de autenticação com JWT
* Bootstrap da aplicação com NestJS + TypeScript * Migração de Node.js puro + JavaScript para NestJS como framework * Estrutura base: AppModule, AppController, health check em /health * loadEnv com busca hierárquica de .env por ambiente * Módulo auth completo com arquitetura em camadas: - AuthController: rotas HTTP de autenticação - AuthService: fachada de negócio - AuthConfigService: leitura centralizada de variáveis de ambiente - AuthTokenService: emissão de JWT próprio da aplicação * Autenticação via LDAP/Active Directory com ldapts * Autenticação via Microsoft OAuth 2.0 (Entra ID) * Proteção CSRF no fluxo OAuth com HMAC state assinado * Endpoint /auth/config para o frontend descobrir provedores ativos * Documentação do módulo em docs/auth.md
This commit is contained in:
parent
6edfd62a47
commit
5bd13e30f1
26
.env.example
26
.env.example
@ -9,3 +9,29 @@ DB_PORT=5432
|
|||||||
DB_USER=omnichannel
|
DB_USER=omnichannel
|
||||||
DB_PASSWORD=change-me
|
DB_PASSWORD=change-me
|
||||||
DB_NAME=omnichannel
|
DB_NAME=omnichannel
|
||||||
|
|
||||||
|
# HTTP/JWT
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
JWT_SECRET=change-this-long-random-secret
|
||||||
|
JWT_EXPIRES_IN=8h
|
||||||
|
|
||||||
|
# Auth providers: ldap,microsoft or only one of them
|
||||||
|
AUTH_PROVIDERS=ldap,microsoft
|
||||||
|
|
||||||
|
# LDAP / Active Directory
|
||||||
|
LDAP_ENABLED=true
|
||||||
|
LDAP_URL=ldaps://kratos.sothistelecom.com:636
|
||||||
|
LDAP_DOMAIN=sothis.com.br
|
||||||
|
LDAP_USER_DN_TEMPLATE={{username}}@sothis.com.br
|
||||||
|
LDAP_SEARCH_BASE=DC=sothistelecom,DC=com
|
||||||
|
LDAP_SEARCH_FILTER=(&(objectClass=user)(sAMAccountName={{username}}))
|
||||||
|
# LDAP_BIND_DN=CN=ldap-reader,OU=Users,DC=example,DC=com
|
||||||
|
# LDAP_BIND_PASSWORD=change-me
|
||||||
|
|
||||||
|
# Microsoft Entra ID OAuth
|
||||||
|
MICROSOFT_ENABLED=false
|
||||||
|
MICROSOFT_TENANT_ID=common
|
||||||
|
MICROSOFT_CLIENT_ID=
|
||||||
|
MICROSOFT_CLIENT_SECRET=
|
||||||
|
MICROSOFT_REDIRECT_URI=http://localhost:3001/auth/oauth/microsoft/callback
|
||||||
|
MICROSOFT_SUCCESS_REDIRECT_URL=http://localhost:3000/login
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,2 +1,5 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
.env*
|
.env*
|
||||||
|
/dist
|
||||||
|
/logs
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
EXPOSE 3000
|
EXPOSE 3001
|
||||||
CMD ["npm", "run", "dev"]
|
CMD ["npm", "run", "dev"]
|
||||||
|
|||||||
260
docs/auth.md
Normal file
260
docs/auth.md
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
# Módulo de Autenticação
|
||||||
|
|
||||||
|
## Visão geral
|
||||||
|
|
||||||
|
O módulo `auth` centraliza toda a lógica de autenticação do Omnichannel. Ele suporta múltiplos provedores de identidade e emite JWT próprio da aplicação, independente de qual provedor foi usado.
|
||||||
|
|
||||||
|
Provedores implementados:
|
||||||
|
|
||||||
|
- **LDAP / Active Directory** — login com usuário e senha do AD corporativo
|
||||||
|
- **Microsoft OAuth (Entra ID)** — login via conta Microsoft com redirect OAuth 2.0
|
||||||
|
|
||||||
|
A arquitetura foi desenhada para facilitar a adição de novos provedores no futuro.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estrutura de arquivos
|
||||||
|
|
||||||
|
```
|
||||||
|
src/modules/auth/
|
||||||
|
├── auth.module.ts # Registro do módulo no NestJS
|
||||||
|
├── auth.controller.ts # Rotas HTTP
|
||||||
|
├── auth.service.ts # Fachada — delega para os providers
|
||||||
|
├── auth.config.ts # Leitura de variáveis de ambiente
|
||||||
|
├── auth-token.service.ts # Emissão de JWT da aplicação
|
||||||
|
├── auth.types.ts # Interfaces TypeScript compartilhadas
|
||||||
|
└── providers/
|
||||||
|
├── ldap-auth.provider.ts # Autenticação LDAP/AD
|
||||||
|
├── microsoft-oauth.provider.ts # Autenticação Microsoft OAuth
|
||||||
|
└── oauth-state.service.ts # Proteção CSRF para OAuth
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rotas disponíveis
|
||||||
|
|
||||||
|
| Método | Rota | Descrição |
|
||||||
|
|--------|---------------------------------|------------------------------------------------|
|
||||||
|
| GET | `/auth/config` | Retorna quais provedores estão habilitados |
|
||||||
|
| POST | `/auth/login` | Login com usuário e senha (LDAP/AD) |
|
||||||
|
| GET | `/auth/oauth/microsoft/start` | Inicia o fluxo OAuth com a Microsoft |
|
||||||
|
| GET | `/auth/oauth/microsoft/callback`| Callback que a Microsoft chama após o login |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variáveis de ambiente
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Servidor
|
||||||
|
PORT=3001
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=uma-chave-longa-e-aleatoria
|
||||||
|
JWT_EXPIRES_IN=8h
|
||||||
|
|
||||||
|
# Provedores ativos (separados por vírgula)
|
||||||
|
AUTH_PROVIDERS=ldap
|
||||||
|
|
||||||
|
# LDAP / Active Directory
|
||||||
|
LDAP_ENABLED=true
|
||||||
|
LDAP_URL=ldaps://servidor-ad:636
|
||||||
|
LDAP_DOMAIN=empresa.com.br
|
||||||
|
LDAP_USER_DN_TEMPLATE={{username}}@empresa.com.br
|
||||||
|
LDAP_SEARCH_BASE=DC=empresa,DC=com
|
||||||
|
LDAP_SEARCH_FILTER=(&(objectClass=user)(sAMAccountName={{username}}))
|
||||||
|
LDAP_TIMEOUT_MS=5000
|
||||||
|
|
||||||
|
# Microsoft Entra ID (desabilitado por padrão)
|
||||||
|
MICROSOFT_ENABLED=false
|
||||||
|
MICROSOFT_TENANT_ID=common
|
||||||
|
MICROSOFT_CLIENT_ID=
|
||||||
|
MICROSOFT_CLIENT_SECRET=
|
||||||
|
MICROSOFT_REDIRECT_URI=http://localhost:3001/auth/oauth/microsoft/callback
|
||||||
|
MICROSOFT_SUCCESS_REDIRECT_URL=http://localhost:3000/login
|
||||||
|
```
|
||||||
|
|
||||||
|
> `JWT_SECRET` deve ser uma string longa e aleatória. Em produção, nunca use o valor padrão do `.env.development`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxo LDAP / Active Directory
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend
|
||||||
|
→ POST /auth/login { username, password }
|
||||||
|
→ AuthController
|
||||||
|
→ AuthService.loginWithLdap()
|
||||||
|
→ LdapAuthProvider.authenticate()
|
||||||
|
→ Conecta no servidor AD (LDAP_URL)
|
||||||
|
→ Faz bind com o usuário e senha
|
||||||
|
→ Se o bind falhar: UnauthorizedException
|
||||||
|
→ Busca dados do usuário no diretório (se LDAP_SEARCH_BASE configurado)
|
||||||
|
→ Monta objeto AuthenticatedUser
|
||||||
|
→ AuthTokenService.issueToken()
|
||||||
|
→ Gera JWT assinado com JWT_SECRET
|
||||||
|
→ Retorna { token, user } para o frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
O AD apenas valida a identidade. O JWT emitido é da aplicação, não do AD.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxo Microsoft OAuth
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Frontend redireciona para GET /auth/oauth/microsoft/start
|
||||||
|
→ Backend gera um state assinado (proteção CSRF)
|
||||||
|
→ Backend redireciona para login.microsoftonline.com
|
||||||
|
|
||||||
|
2. Usuário autentica na Microsoft
|
||||||
|
|
||||||
|
3. Microsoft chama GET /auth/oauth/microsoft/callback?code=...&state=...
|
||||||
|
→ Backend valida o state (assinatura + expiração)
|
||||||
|
→ Backend troca o code por access_token (chamada server-to-server)
|
||||||
|
→ Backend consulta Microsoft Graph /me para obter dados do usuário
|
||||||
|
→ AuthTokenService.issueToken() gera JWT próprio
|
||||||
|
→ Backend redireciona para MICROSOFT_SUCCESS_REDIRECT_URL?token=...
|
||||||
|
|
||||||
|
4. Frontend salva o token e navega para /home
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proteção CSRF com OAuth State
|
||||||
|
|
||||||
|
O `OAuthStateService` protege o fluxo OAuth contra ataques de CSRF.
|
||||||
|
|
||||||
|
**Como funciona:**
|
||||||
|
|
||||||
|
1. No início do fluxo, o backend cria um state:
|
||||||
|
- Gera um nonce aleatório + timestamp
|
||||||
|
- Converte para base64url
|
||||||
|
- Assina com HMAC-SHA256 usando o `JWT_SECRET`
|
||||||
|
- Formato final: `payload.assinatura`
|
||||||
|
|
||||||
|
2. No callback, o backend verifica:
|
||||||
|
- O state tem os dois pedaços (`payload.assinatura`)
|
||||||
|
- A assinatura é válida (recalcula e compara com `timingSafeEqual`)
|
||||||
|
- O state não expirou (padrão: 10 minutos, configurável via `MICROSOFT_STATE_MAX_AGE_MS`)
|
||||||
|
|
||||||
|
Se qualquer verificação falhar, o callback é rejeitado com `400 Bad Request`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JWT da aplicação
|
||||||
|
|
||||||
|
Após qualquer autenticação bem-sucedida, o `AuthTokenService` emite um JWT com o seguinte payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sub": "identificador-do-usuario",
|
||||||
|
"name": "Nome Completo",
|
||||||
|
"email": "usuario@empresa.com",
|
||||||
|
"provider": "ldap",
|
||||||
|
"username": "usuario"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
O `sub` é atualmente o email ou identificador externo. Quando houver banco de dados, deve ser substituído pelo ID interno da tabela `users`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Como adicionar um novo provedor
|
||||||
|
|
||||||
|
1. Crie o arquivo em `src/modules/auth/providers/novo-provedor.provider.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthConfigService } from '../auth.config';
|
||||||
|
import { AuthTokenService } from '../auth-token.service';
|
||||||
|
import { AuthResult } from '../auth.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NovoProvedorProvider {
|
||||||
|
constructor(
|
||||||
|
private readonly authConfig: AuthConfigService,
|
||||||
|
private readonly authToken: AuthTokenService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async authenticate(/* dados necessários */): Promise<AuthResult> {
|
||||||
|
// 1. Valide as credenciais no provedor externo
|
||||||
|
// 2. Monte o objeto AuthenticatedUser
|
||||||
|
// 3. Emita o token com this.authToken.issueToken(user)
|
||||||
|
// 4. Retorne { token, user }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Registre o provider em `auth.module.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
providers: [
|
||||||
|
AuthConfigService,
|
||||||
|
AuthService,
|
||||||
|
AuthTokenService,
|
||||||
|
LdapAuthProvider,
|
||||||
|
MicrosoftOAuthProvider,
|
||||||
|
OAuthStateService,
|
||||||
|
NovoProvedorProvider, // adicione aqui
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Injete no `AuthService` e exponha o método necessário:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
constructor(
|
||||||
|
private readonly authConfig: AuthConfigService,
|
||||||
|
private readonly ldapAuthProvider: LdapAuthProvider,
|
||||||
|
private readonly microsoftOAuthProvider: MicrosoftOAuthProvider,
|
||||||
|
private readonly novoProvedorProvider: NovoProvedorProvider, // injete aqui
|
||||||
|
) {}
|
||||||
|
|
||||||
|
loginComNovoProvedor(dados: any) {
|
||||||
|
return this.novoProvedorProvider.authenticate(dados);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Adicione a rota no `AuthController`.
|
||||||
|
|
||||||
|
5. Se o provedor precisar de configuração, adicione as variáveis no `AuthConfigService` e no `.env`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagnóstico de problemas
|
||||||
|
|
||||||
|
### Login LDAP falha com `UnauthorizedException`
|
||||||
|
|
||||||
|
- Verifique se `LDAP_URL` está acessível a partir do servidor backend
|
||||||
|
- Confirme que `LDAP_DOMAIN` ou `LDAP_USER_DN_TEMPLATE` está correto
|
||||||
|
- Teste a conectividade: `ldapsearch -H ldaps://servidor:636 -x`
|
||||||
|
- Verifique `LDAP_TIMEOUT_MS` — servidores lentos podem estar expirando
|
||||||
|
- O erro é genérico intencionalmente para não vazar informações. Adicione um `console.log(_error)` temporário no `catch` do `ldap-auth.provider.ts` para ver o erro real
|
||||||
|
|
||||||
|
### Login Microsoft falha com `400 Bad Request`
|
||||||
|
|
||||||
|
- O state expirou (padrão: 10 minutos). Se o usuário demorou muito na tela da Microsoft, repita o fluxo
|
||||||
|
- Verifique se `MICROSOFT_REDIRECT_URI` no `.env` é idêntico ao cadastrado no Azure App Registration
|
||||||
|
- Confirme que `MICROSOFT_CLIENT_ID` e `MICROSOFT_CLIENT_SECRET` estão corretos e não expiraram
|
||||||
|
|
||||||
|
### Token inválido no frontend
|
||||||
|
|
||||||
|
- Verifique se `JWT_SECRET` não mudou entre deploys — isso invalida todos os tokens emitidos anteriormente
|
||||||
|
- Confirme que o frontend está enviando o header `Authorization: Bearer <token>`
|
||||||
|
|
||||||
|
### `GET /auth/config` retorna os provedores errados
|
||||||
|
|
||||||
|
- Verifique `LDAP_ENABLED` e `MICROSOFT_ENABLED` no `.env`
|
||||||
|
- Reinicie o servidor — variáveis de ambiente são lidas na inicialização
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O que ainda falta para produção
|
||||||
|
|
||||||
|
- [ ] Tabela `users` no banco de dados
|
||||||
|
- [ ] Tabela `auth_identities` para vincular provedores externos ao usuário interno
|
||||||
|
- [ ] `sub` do JWT usando ID interno do banco, não email externo
|
||||||
|
- [ ] Guard NestJS para proteger rotas privadas (`@UseGuards(AuthGuard)`)
|
||||||
|
- [ ] Roles e permissões
|
||||||
|
- [ ] Auditoria de login
|
||||||
|
- [ ] Trocar token na query string por cookie HTTP-only (reduz exposição no browser)
|
||||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
4328
package-lock.json
generated
4328
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@ -3,19 +3,28 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "cross-env NODE_ENV=production node src/infra/http/server.js",
|
"build": "nest build",
|
||||||
"dev": "cross-env NODE_ENV=development nodemon src/infra/http/server.js"
|
"start": "cross-env NODE_ENV=production node dist/main.js",
|
||||||
|
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||||
|
"start:dev": "npm run dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^16.4.5",
|
"@nestjs/common": "^11.1.19",
|
||||||
"ldapjs": "^3.0.7",
|
"@nestjs/core": "^11.1.19",
|
||||||
"winston": "^3.13.0",
|
"@nestjs/platform-express": "^11.1.19",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"ldapts": "^8.1.7",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
|
"winston": "^3.19.0",
|
||||||
"winston-daily-rotate-file": "^5.0.0"
|
"winston-daily-rotate-file": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.6.0",
|
"@nestjs/cli": "^11.0.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^25.6.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"nodemon": "^3.1.0",
|
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/app.controller.ts
Normal file
9
src/app.controller.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
@Get('health')
|
||||||
|
health() {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/app.module.ts
Normal file
9
src/app.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [AppController],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
19
src/infra/config/load-env.ts
Normal file
19
src/infra/config/load-env.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export function loadEnv() {
|
||||||
|
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||||
|
const candidates = [
|
||||||
|
path.resolve(process.cwd(), `.env.${nodeEnv}`),
|
||||||
|
path.resolve(process.cwd(), '.env'),
|
||||||
|
path.resolve(process.cwd(), '..', `.env.${nodeEnv}`),
|
||||||
|
path.resolve(process.cwd(), '..', '.env'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const envPath = candidates.find((candidate) => fs.existsSync(candidate));
|
||||||
|
|
||||||
|
if (envPath) {
|
||||||
|
dotenv.config({ path: envPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +0,0 @@
|
|||||||
const path = require('path');
|
|
||||||
const dotenv = require('dotenv');
|
|
||||||
|
|
||||||
function loadEnv() {
|
|
||||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
||||||
const envPath = path.resolve(process.cwd(), `.env.${nodeEnv}`);
|
|
||||||
dotenv.config({ path: envPath });
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = loadEnv;
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
const winston = require('winston');
|
|
||||||
const path = require('path');
|
|
||||||
require('winston-daily-rotate-file');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
// Verifica se a pasta de logs existe; se nao, cria
|
|
||||||
const logsDir = path.join(__dirname, '../../../logs');
|
|
||||||
if (!fs.existsSync(logsDir)) {
|
|
||||||
fs.mkdirSync(logsDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configuracao do logger com winston
|
|
||||||
const logger = winston.createLogger({
|
|
||||||
level: 'info',
|
|
||||||
format: winston.format.combine(
|
|
||||||
winston.format.timestamp({
|
|
||||||
format: 'YYYY-MM-DD HH:mm:ss'
|
|
||||||
}),
|
|
||||||
winston.format.errors({ stack: true }),
|
|
||||||
winston.format.json()
|
|
||||||
),
|
|
||||||
transports: [
|
|
||||||
// Log geral da aplicacao
|
|
||||||
new winston.transports.DailyRotateFile({
|
|
||||||
filename: path.join(logsDir, 'app-%DATE%.log'),
|
|
||||||
datePattern: 'YYYY-MM-DD',
|
|
||||||
zippedArchive: true,
|
|
||||||
maxSize: '5m',
|
|
||||||
maxFiles: '10d',
|
|
||||||
options: {
|
|
||||||
flags: 'w'
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
// Log de erros
|
|
||||||
new winston.transports.DailyRotateFile({
|
|
||||||
filename: path.join(logsDir, 'error-%DATE%.log'),
|
|
||||||
level: 'error',
|
|
||||||
datePattern: 'YYYY-MM-DD',
|
|
||||||
zippedArchive: true,
|
|
||||||
maxSize: '5m',
|
|
||||||
maxFiles: '10d',
|
|
||||||
options: {
|
|
||||||
flags: 'w'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log no console para todos os ambientes (pm2 logs)
|
|
||||||
logger.add(new winston.transports.Console({
|
|
||||||
format: winston.format.combine(
|
|
||||||
winston.format.colorize(),
|
|
||||||
winston.format.printf((info) => {
|
|
||||||
const { timestamp, level, message, stack, ...meta } = info;
|
|
||||||
let logMessage = `${timestamp} [${level}]: ${stack || message}`;
|
|
||||||
if (Object.keys(meta).length) {
|
|
||||||
logMessage += ` ${JSON.stringify(meta, null, 2)}`;
|
|
||||||
}
|
|
||||||
return logMessage;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Funcoes utilitarias
|
|
||||||
const logError = (error, context = '') => {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
logger.error(`${context} - ${error.message}`, { stack: error.stack });
|
|
||||||
} else {
|
|
||||||
logger.error(`${context} - ${error}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const logInfo = (message, meta = {}) => {
|
|
||||||
logger.info(message, meta);
|
|
||||||
};
|
|
||||||
|
|
||||||
const logWarning = (message, meta = {}) => {
|
|
||||||
logger.warn(message, meta);
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
logger,
|
|
||||||
logError,
|
|
||||||
logInfo,
|
|
||||||
logWarning
|
|
||||||
};
|
|
||||||
3
src/infra/shared/logger.ts
Normal file
3
src/infra/shared/logger.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const appLogger = new Logger('Application');
|
||||||
23
src/main.ts
Normal file
23
src/main.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { loadEnv } from './infra/config/load-env';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||||
|
const port = Number(process.env.PORT || process.env.BACKEND_PORT || 3001);
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: frontendUrl,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.listen(port);
|
||||||
|
Logger.log(`Backend ouvindo na porta ${port}`, 'Bootstrap');
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import * as ldap from 'ldapjs';
|
|
||||||
// import { validateUser } from './repository';
|
|
||||||
|
|
||||||
interface LoginData {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function authenticateUserAD(loginData: LoginData): Promise<{ message: string }> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const userDN = `${loginData.username}@sothis.com.br`;
|
|
||||||
|
|
||||||
const client = ldap.createClient({
|
|
||||||
url: 'ldap://kratos.sothistelecom.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
client.bind(userDN, loginData.password, (err) => {
|
|
||||||
if (err) {
|
|
||||||
reject(new Error('Autenticação falhou: ' + err.message));
|
|
||||||
} else {
|
|
||||||
resolve({ message: 'Autenticação bem-sucedida' });
|
|
||||||
}
|
|
||||||
client.unbind();
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { authenticateUserAD };
|
|
||||||
39
src/modules/auth/auth-token.service.ts
Normal file
39
src/modules/auth/auth-token.service.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
import { AuthConfigService } from './auth.config';
|
||||||
|
import { AuthenticatedUser } from './auth.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthTokenService {
|
||||||
|
constructor(private readonly authConfig: AuthConfigService) {}
|
||||||
|
|
||||||
|
issueToken(user: AuthenticatedUser) {
|
||||||
|
const config = this.authConfig.getConfig();
|
||||||
|
|
||||||
|
if (!config.jwtSecret) {
|
||||||
|
throw new Error('JWT_SECRET nao configurado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
provider: user.provider,
|
||||||
|
username: user.username,
|
||||||
|
},
|
||||||
|
config.jwtSecret,
|
||||||
|
{
|
||||||
|
subject: user.id,
|
||||||
|
expiresIn: config.jwtExpiresIn,
|
||||||
|
} as jwt.SignOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertJwtConfig() {
|
||||||
|
const config = this.authConfig.getConfig();
|
||||||
|
|
||||||
|
if (!config.jwtSecret) {
|
||||||
|
throw new Error('JWT_SECRET nao configurado');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/modules/auth/auth.config.ts
Normal file
59
src/modules/auth/auth.config.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthConfig } from './auth.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthConfigService {
|
||||||
|
getConfig(): AuthConfig {
|
||||||
|
const providers = (process.env.AUTH_PROVIDERS || 'ldap,microsoft')
|
||||||
|
.split(',')
|
||||||
|
.map((provider) => provider.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
jwtSecret: process.env.JWT_SECRET,
|
||||||
|
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '8h',
|
||||||
|
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000',
|
||||||
|
ldap: {
|
||||||
|
enabled: providers.includes('ldap') && this.getBooleanEnv('LDAP_ENABLED', true),
|
||||||
|
url: process.env.LDAP_URL,
|
||||||
|
domain: process.env.LDAP_DOMAIN,
|
||||||
|
userDnTemplate: process.env.LDAP_USER_DN_TEMPLATE,
|
||||||
|
searchBase: process.env.LDAP_SEARCH_BASE,
|
||||||
|
searchFilter: process.env.LDAP_SEARCH_FILTER || '(sAMAccountName={{username}})',
|
||||||
|
bindDn: process.env.LDAP_BIND_DN,
|
||||||
|
bindPassword: process.env.LDAP_BIND_PASSWORD,
|
||||||
|
timeoutMs: Number(process.env.LDAP_TIMEOUT_MS || 5000),
|
||||||
|
},
|
||||||
|
microsoft: {
|
||||||
|
enabled:
|
||||||
|
providers.includes('microsoft') && this.getBooleanEnv('MICROSOFT_ENABLED', false),
|
||||||
|
tenantId: process.env.MICROSOFT_TENANT_ID || 'common',
|
||||||
|
clientId: process.env.MICROSOFT_CLIENT_ID,
|
||||||
|
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
|
||||||
|
redirectUri: process.env.MICROSOFT_REDIRECT_URI,
|
||||||
|
successRedirectUrl: process.env.MICROSOFT_SUCCESS_REDIRECT_URL,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getPublicConfig() {
|
||||||
|
const config = this.getConfig();
|
||||||
|
|
||||||
|
return {
|
||||||
|
providers: {
|
||||||
|
ldap: config.ldap.enabled,
|
||||||
|
microsoft: config.microsoft.enabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBooleanEnv(name: string, defaultValue = false) {
|
||||||
|
const value = process.env[name];
|
||||||
|
|
||||||
|
if (value === undefined || value === '') {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/modules/auth/auth.controller.ts
Normal file
34
src/modules/auth/auth.controller.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Body, Controller, Get, Post, Query, Res } from '@nestjs/common';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { LoginData } from './auth.types';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Get('config')
|
||||||
|
getConfig() {
|
||||||
|
return this.authService.getPublicConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
login(@Body() body: LoginData) {
|
||||||
|
return this.authService.loginWithLdap(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('oauth/microsoft/start')
|
||||||
|
startMicrosoftLogin(@Res() response: any) {
|
||||||
|
return response.redirect(this.authService.getMicrosoftAuthorizeUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('oauth/microsoft/callback')
|
||||||
|
async microsoftCallback(@Query() query: { code?: string; state?: string }, @Res() response: any) {
|
||||||
|
const authResult = await this.authService.loginWithMicrosoftCallback(query);
|
||||||
|
const redirectUrl = new URL(this.authService.getMicrosoftSuccessRedirectUrl());
|
||||||
|
|
||||||
|
redirectUrl.searchParams.set('token', authResult.token);
|
||||||
|
redirectUrl.searchParams.set('provider', authResult.user.provider);
|
||||||
|
|
||||||
|
return response.redirect(redirectUrl.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/modules/auth/auth.module.ts
Normal file
21
src/modules/auth/auth.module.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthConfigService } from './auth.config';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { AuthTokenService } from './auth-token.service';
|
||||||
|
import { LdapAuthProvider } from './providers/ldap-auth.provider';
|
||||||
|
import { MicrosoftOAuthProvider } from './providers/microsoft-oauth.provider';
|
||||||
|
import { OAuthStateService } from './providers/oauth-state.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
AuthConfigService,
|
||||||
|
AuthService,
|
||||||
|
AuthTokenService,
|
||||||
|
LdapAuthProvider,
|
||||||
|
MicrosoftOAuthProvider,
|
||||||
|
OAuthStateService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
42
src/modules/auth/auth.types.ts
Normal file
42
src/modules/auth/auth.types.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
export interface LoginData {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticatedUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
username: string;
|
||||||
|
provider: 'ldap' | 'microsoft';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResult {
|
||||||
|
token: string;
|
||||||
|
user: AuthenticatedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthConfig {
|
||||||
|
jwtSecret?: string;
|
||||||
|
jwtExpiresIn: string;
|
||||||
|
frontendUrl: string;
|
||||||
|
ldap: {
|
||||||
|
enabled: boolean;
|
||||||
|
url?: string;
|
||||||
|
domain?: string;
|
||||||
|
userDnTemplate?: string;
|
||||||
|
searchBase?: string;
|
||||||
|
searchFilter: string;
|
||||||
|
bindDn?: string;
|
||||||
|
bindPassword?: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
};
|
||||||
|
microsoft: {
|
||||||
|
enabled: boolean;
|
||||||
|
tenantId: string;
|
||||||
|
clientId?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
redirectUri?: string;
|
||||||
|
successRedirectUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
130
src/modules/auth/providers/ldap-auth.provider.ts
Normal file
130
src/modules/auth/providers/ldap-auth.provider.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Client } from 'ldapts';
|
||||||
|
import { AuthConfigService } from '../auth.config';
|
||||||
|
import { AuthTokenService } from '../auth-token.service';
|
||||||
|
import { AuthResult, LoginData } from '../auth.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LdapAuthProvider {
|
||||||
|
constructor(
|
||||||
|
private readonly authConfig: AuthConfigService,
|
||||||
|
private readonly authToken: AuthTokenService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async authenticate({ username, password }: LoginData): Promise<AuthResult> {
|
||||||
|
const config = this.authConfig.getConfig();
|
||||||
|
|
||||||
|
if (!config.ldap.enabled) {
|
||||||
|
throw new ForbiddenException('Login AD/LDAP desabilitado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.ldap.url) {
|
||||||
|
throw new Error('LDAP_URL nao configurado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
throw new UnauthorizedException('Usuario e senha sao obrigatorios');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
url: config.ldap.url,
|
||||||
|
timeout: config.ldap.timeoutMs,
|
||||||
|
connectTimeout: config.ldap.timeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (config.ldap.bindDn && config.ldap.bindPassword) {
|
||||||
|
await client.bind(config.ldap.bindDn, config.ldap.bindPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPrincipal = this.buildUserPrincipal(username);
|
||||||
|
await client.bind(userPrincipal, password);
|
||||||
|
|
||||||
|
const directoryUser = await this.searchUser(client, username);
|
||||||
|
const user = {
|
||||||
|
id: directoryUser?.email || userPrincipal,
|
||||||
|
name: directoryUser?.name || username,
|
||||||
|
email:
|
||||||
|
directoryUser?.email ||
|
||||||
|
(config.ldap.domain ? `${username}@${config.ldap.domain}` : null),
|
||||||
|
username: directoryUser?.username || username,
|
||||||
|
provider: 'ldap' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: this.authToken.issueToken(user),
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
throw new UnauthorizedException('Autenticacao AD/LDAP falhou');
|
||||||
|
} finally {
|
||||||
|
await client.unbind().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildUserPrincipal(username: string) {
|
||||||
|
const config = this.authConfig.getConfig();
|
||||||
|
|
||||||
|
if (config.ldap.userDnTemplate) {
|
||||||
|
return config.ldap.userDnTemplate.replaceAll('{{username}}', username);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.ldap.domain) {
|
||||||
|
return `${username}@${config.ldap.domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async searchUser(client: Client, username: string) {
|
||||||
|
const config = this.authConfig.getConfig();
|
||||||
|
|
||||||
|
if (!config.ldap.searchBase) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = config.ldap.searchFilter.replaceAll('{{username}}', username);
|
||||||
|
const { searchEntries } = await client.search(config.ldap.searchBase, {
|
||||||
|
scope: 'sub',
|
||||||
|
filter,
|
||||||
|
attributes: [
|
||||||
|
'cn',
|
||||||
|
'displayName',
|
||||||
|
'givenName',
|
||||||
|
'sn',
|
||||||
|
'mail',
|
||||||
|
'userPrincipalName',
|
||||||
|
'sAMAccountName',
|
||||||
|
],
|
||||||
|
sizeLimit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry = searchEntries[0] as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const givenName = this.getFirstValue(entry.givenName);
|
||||||
|
const surname = this.getFirstValue(entry.sn);
|
||||||
|
const fullName = [givenName, surname].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
name:
|
||||||
|
this.getFirstValue(entry.displayName) ||
|
||||||
|
fullName ||
|
||||||
|
this.getFirstValue(entry.cn) ||
|
||||||
|
username,
|
||||||
|
email: this.getFirstValue(entry.mail) || this.getFirstValue(entry.userPrincipalName) || null,
|
||||||
|
username: this.getFirstValue(entry.sAMAccountName) || username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFirstValue(value: unknown): string | null {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return String(value[0] || '') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value ? String(value) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/modules/auth/providers/microsoft-oauth.provider.ts
Normal file
119
src/modules/auth/providers/microsoft-oauth.provider.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthConfigService } from '../auth.config';
|
||||||
|
import { AuthTokenService } from '../auth-token.service';
|
||||||
|
import { AuthResult } from '../auth.types';
|
||||||
|
import { OAuthStateService } from './oauth-state.service';
|
||||||
|
|
||||||
|
const MICROSOFT_SCOPE = 'openid profile email User.Read';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MicrosoftOAuthProvider {
|
||||||
|
constructor(
|
||||||
|
private readonly authConfig: AuthConfigService,
|
||||||
|
private readonly authToken: AuthTokenService,
|
||||||
|
private readonly oauthState: OAuthStateService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getAuthorizeUrl() {
|
||||||
|
const config = this.authConfig.getConfig();
|
||||||
|
this.assertMicrosoftConfig();
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: config.microsoft.clientId!,
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: config.microsoft.redirectUri!,
|
||||||
|
response_mode: 'query',
|
||||||
|
scope: MICROSOFT_SCOPE,
|
||||||
|
state: this.oauthState.createSignedState(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return `https://login.microsoftonline.com/${config.microsoft.tenantId}/oauth2/v2.0/authorize?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticateCallback(query: { code?: string; state?: string }): Promise<AuthResult> {
|
||||||
|
if (!query.code || !query.state || !this.oauthState.verifySignedState(query.state)) {
|
||||||
|
throw new BadRequestException('Callback Microsoft invalido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResponse = await this.exchangeCode(query.code);
|
||||||
|
const microsoftUser = await this.getMicrosoftUser(tokenResponse.access_token);
|
||||||
|
const email = microsoftUser.mail || microsoftUser.userPrincipalName;
|
||||||
|
const user = {
|
||||||
|
id: microsoftUser.id || email,
|
||||||
|
name: microsoftUser.displayName || email,
|
||||||
|
email,
|
||||||
|
username: microsoftUser.userPrincipalName || email,
|
||||||
|
provider: 'microsoft' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: this.authToken.issueToken(user),
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertMicrosoftConfig() {
|
||||||
|
const config = this.authConfig.getConfig();
|
||||||
|
|
||||||
|
if (!config.microsoft.enabled) {
|
||||||
|
throw new ForbiddenException('Login Microsoft desabilitado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = [
|
||||||
|
['MICROSOFT_CLIENT_ID', config.microsoft.clientId],
|
||||||
|
['MICROSOFT_CLIENT_SECRET', config.microsoft.clientSecret],
|
||||||
|
['MICROSOFT_REDIRECT_URI', config.microsoft.redirectUri],
|
||||||
|
].filter(([, value]) => !value);
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
throw new Error(`${missing.map(([name]) => name).join(', ')} nao configurado`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exchangeCode(code: string) {
|
||||||
|
const config = this.authConfig.getConfig();
|
||||||
|
this.assertMicrosoftConfig();
|
||||||
|
const tokenUrl = `https://login.microsoftonline.com/${config.microsoft.tenantId}/oauth2/v2.0/token`;
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: config.microsoft.clientId!,
|
||||||
|
client_secret: config.microsoft.clientSecret!,
|
||||||
|
code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: config.microsoft.redirectUri!,
|
||||||
|
scope: MICROSOFT_SCOPE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new UnauthorizedException('Falha ao trocar codigo Microsoft por token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMicrosoftUser(accessToken: string) {
|
||||||
|
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new UnauthorizedException('Falha ao consultar usuario Microsoft');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/modules/auth/providers/oauth-state.service.ts
Normal file
62
src/modules/auth/providers/oauth-state.service.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { AuthConfigService } from '../auth.config';
|
||||||
|
import { AuthTokenService } from '../auth-token.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OAuthStateService {
|
||||||
|
constructor(
|
||||||
|
private readonly authConfig: AuthConfigService,
|
||||||
|
private readonly authToken: AuthTokenService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
createSignedState() {
|
||||||
|
this.authToken.assertJwtConfig();
|
||||||
|
const config = this.authConfig.getConfig();
|
||||||
|
const payload = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
nonce: crypto.randomBytes(16).toString('hex'),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}),
|
||||||
|
).toString('base64url');
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac('sha256', config.jwtSecret!)
|
||||||
|
.update(payload)
|
||||||
|
.digest('base64url');
|
||||||
|
|
||||||
|
return `${payload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifySignedState(state: string) {
|
||||||
|
this.authToken.assertJwtConfig();
|
||||||
|
const config = this.authConfig.getConfig();
|
||||||
|
const [payload, signature] = String(state || '').split('.');
|
||||||
|
|
||||||
|
if (!payload || !signature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedSignature = crypto
|
||||||
|
.createHmac('sha256', config.jwtSecret!)
|
||||||
|
.update(payload)
|
||||||
|
.digest('base64url');
|
||||||
|
const signatureBuffer = Buffer.from(signature);
|
||||||
|
const expectedSignatureBuffer = Buffer.from(expectedSignature);
|
||||||
|
|
||||||
|
if (
|
||||||
|
signatureBuffer.length !== expectedSignatureBuffer.length ||
|
||||||
|
!crypto.timingSafeEqual(signatureBuffer, expectedSignatureBuffer)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedPayload = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||||
|
const maxAgeMs = Number(process.env.MICROSOFT_STATE_MAX_AGE_MS || 10 * 60 * 1000);
|
||||||
|
|
||||||
|
return Date.now() - parsedPayload.createdAt <= maxAgeMs;
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": false,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"sourceMap": true,
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"incremental": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictPropertyInitialization": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user