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:
Rafael Alves Lopes 2026-05-08 17:10:50 -03:00
parent 6edfd62a47
commit 5bd13e30f1
29 changed files with 4849 additions and 527 deletions

View File

@ -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
View File

@ -1,2 +1,5 @@
/node_modules /node_modules
.env* .env*
/dist
/logs
*.tsbuildinfo

View File

@ -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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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
View File

@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class AppController {
@Get('health')
health() {
return { status: 'ok' };
}
}

View File

9
src/app.module.ts Normal file
View 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 {}

View 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 });
}
}

View File

@ -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;

View File

@ -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
};

View File

@ -0,0 +1,3 @@
import { Logger } from '@nestjs/common';
export const appLogger = new Logger('Application');

View File

23
src/main.ts Normal file
View 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();

View File

@ -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 };

View 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');
}
}
}

View 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());
}
}

View 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());
}
}

View 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 {}

View 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;
};
}

View 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;
}
}

View 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();
}
}

View 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;
}
}
}

View File

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

21
tsconfig.json Normal file
View 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"]
}