Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ TZ=UTC
PORT=3000
NODE_ENV=development

# Optional: CORS (used by Application). Comma-separated origins, or * for all.
CORS_ORIGINS=*
# CORS (used by Application). Explicit comma-separated origins. * is rejected.
CORS_ORIGINS=http://localhost:5173
CORS_METHODS=HEAD,GET,POST,PUT,PATCH,DELETE
CORS_ALLOWED_HEADERS=Content-Type,Authorization
CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With

STORAGE_PATH=./uploads

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Os nomes e os valores padrão locais estão em `.env.example`. Ajuste o `.env` s
| Grupo | Variáveis | Uso local |
| --- | --- | --- |
| Runtime | `TZ`, `PORT`, `NODE_ENV`, `STORAGE_PATH` | Os padrões do exemplo bastam. |
| CORS | `CORS_ORIGINS`, `CORS_METHODS`, `CORS_ALLOWED_HEADERS` | `*` ou a origem do painel. |
| CORS | `CORS_ORIGINS`, `CORS_METHODS`, `CORS_ALLOWED_HEADERS` | Origem explícita do painel (ex. `http://localhost:5173`). `*` não é aceito. |
| Postgres | `PG_DATABASE`, `PG_HOST`, `PG_PORT`, `PG_USERNAME`, `PG_PASSWORD`, `PG_MIGRATION_USERNAME`, `PG_MIGRATION_PASSWORD` | O Compose usa `PG_DATABASE`, `PG_USERNAME`, `PG_PASSWORD` e `PG_PORT`. A API usa `PG_*`. |
| Auth, e-mail, captcha | `JWT_SECRET`, `SMTP_*`, `RECAPTCHA_SECRET_KEY` | Login, troca de senha e reCAPTCHA. |
| Painel | `PAINEL_BASE_URL` | Padrão local: `http://localhost:5173`. |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"pg": "^8.16.3",
"puppeteer": "24.28.0",
"q": "1.5.1",
"rate-limiter-flexible": "11.2.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"request": "2.88.2",
Expand Down
67 changes: 67 additions & 0 deletions src/application/RateLimitMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
HttpRequest, HttpResponse
} from '@/library/http/common'
import { HttpError } from '@/library/http/error/HttpError'
import { TooManyRequestsError } from '@/library/http/error/TooManyRequestsError'
import { NextHandler, RequestHandler } from '@/library/http/Server'

export interface RateLimiter {
get(key: string): Promise<{ remainingPoints: number; msBeforeNext: number } | null>
consume(key: string, points?: number): Promise<unknown>
}

interface Dependencies {
limiter: RateLimiter
isFailure: (response: HttpResponse | HttpError) => boolean
}

export class RateLimitMiddleware implements RequestHandler {
private readonly limiter: RateLimiter
private readonly isFailure: (response: HttpResponse | HttpError) => boolean

constructor(dependencies: Dependencies) {
this.limiter = dependencies.limiter
this.isFailure = dependencies.isFailure
}

async handle(request: HttpRequest, next: NextHandler): Promise<HttpResponse | HttpError> {
const key = clientIp(request)
const current = await this.limiter.get(key)
if (current !== null && current.remainingPoints <= 0) {
return new TooManyRequestsError({
message: 'Muitas tentativas de login. Tente novamente em 15 minutos.'
})
}

const response = await next()
if (this.isFailure(response)) {
await this.limiter.consume(key).catch(() => undefined)
}

return response
}
}

export function clientIp(request: HttpRequest): string {
const forwarded = headerValue(request, 'x-forwarded-for')
if (forwarded) {
return forwarded.split(',')[0]?.trim() || 'unknown'
}

const realIp = headerValue(request, 'x-real-ip')
if (realIp) {
return realIp
}

return 'unknown'
}

function headerValue(request: HttpRequest, name: string): string | undefined {
const value = request.headers[name]
if (typeof value !== 'string') {
return undefined
}

const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
11 changes: 8 additions & 3 deletions src/application/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import { generatePreview, reportPreview } from '../reports/controller'
import { routes as createEstadoRoutes } from './estado'
import { routes as createFaseSucessionalRoutes } from './fase-sucessional'
import { routes as createPaisRoutes } from './pais'
import { routes as createSoloRoutes } from './solo'
import { assertCookieSafeOrigins } from './parseCorsOrigins'
import { routes as createRelevoRoutes } from './relevo'
import { routes as createSoloRoutes } from './solo'
import { routes as createUsuarioSessaoRoutes } from './usuarioSessao'
import { routes as createVegetacaoRoutes } from './vegetacao'

interface CorsParameters {
Expand Down Expand Up @@ -63,14 +65,17 @@ export function createApp({
...createSoloRoutes(knex),
...createRelevoRoutes(knex),
...createFaseSucessionalRoutes(knex),
...createVegetacaoRoutes(knex)
...createVegetacaoRoutes(knex),
...createUsuarioSessaoRoutes(knex)
]
const origins = assertCookieSafeOrigins(cors.origins)
const application = new ExpressApplication({ logger })

application
.use(makeHelmet(securityConfig))
.use(makeCors({
origin: cors.origins,
origin: origins,
credentials: true,
methods: cors.methods,
allowedHeaders: cors.allowedHeaders
}))
Expand Down
7 changes: 4 additions & 3 deletions src/application/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import { ConsoleLogger } from '@/infrastructure/ConsoleLogger'

import legacyRoutes from '../routes'
import { createApp } from './create-app'
import { parseCorsOrigins } from './parseCorsOrigins'

const environment = process.env.NODE_ENV ?? 'development'

const corsOriginsRaw = process.env.CORS_ORIGINS ?? '*'
const corsOrigins = corsOriginsRaw === '*' ? '*' : corsOriginsRaw.split(',')
const corsOrigins = parseCorsOrigins(process.env.CORS_ORIGINS)
const corsMethods = process.env.CORS_METHODS ?? 'HEAD,GET,POST,PUT,PATCH,DELETE'
const corsAllowedHeaders = process.env.CORS_ALLOWED_HEADERS ?? 'Content-Type,Authorization'
const corsAllowedHeaders = process.env.CORS_ALLOWED_HEADERS
?? 'Content-Type,Authorization,X-Requested-With'

const logger = new ConsoleLogger()
const application = createApp({
Expand Down
24 changes: 24 additions & 0 deletions src/application/parseCorsOrigins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export function parseCorsOrigins(raw: string | undefined): string[] {
if (raw === undefined || raw.trim() === '') {
throw new Error('CORS_ORIGINS must be an explicit comma-separated list of origins')
}

const origins = raw.split(',').map(origin => origin.trim()).filter(origin => origin.length > 0)
if (origins.length === 0 || origins.includes('*')) {
throw new Error('CORS_ORIGINS must be an explicit list of origins and must not contain *')
}

return origins
}

export function assertCookieSafeOrigins(origins: string | string[]): string[] {
const list = (Array.isArray(origins) ? origins : [origins])
.map(origin => origin.trim())
.filter(origin => origin.length > 0)

if (list.length === 0 || list.includes('*')) {
throw new Error('CORS origins must be an explicit list and must not contain *')
}

return list
}
122 changes: 122 additions & 0 deletions src/application/usuarioSessao/EncerraSessaoController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { type ApagaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/ApagaUsuarioSessaoUseCase'
import { type ApagaUsuarioSessoesUseCase } from '@/domain/usuarioSessao/ApagaUsuarioSessoesUseCase'
import { type BuscaUsuarioSessaoPorHashUseCase } from '@/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase'
import { type AccessToken } from '@/library/auth/AccessToken'
import { type RefreshToken } from '@/library/auth/RefreshToken'
import {
HttpRequest, HttpResponse, StatusCode
} from '@/library/http/common'
import { HttpError } from '@/library/http/error/HttpError'
import { InternalServerError } from '@/library/http/error/InternalServerError'
import { type NextHandler, type RequestHandler } from '@/library/http/Server'

import { serializeClearedRefreshCookie } from './refreshCookie'
import {
hasCsrfHeader,
looksLikeBrowserRequest,
logoutAllRequested,
notAuthorized,
readBearerAccess,
resolveRefreshToken
} from './sessaoHttp'

interface Dependencies {
refreshToken: RefreshToken
accessToken: AccessToken
buscaUsuarioSessaoPorHashUseCase: BuscaUsuarioSessaoPorHashUseCase
apagaUsuarioSessaoUseCase: ApagaUsuarioSessaoUseCase
apagaUsuarioSessoesUseCase: ApagaUsuarioSessoesUseCase
}

export class EncerraSessaoController implements RequestHandler {
private readonly refreshToken: RefreshToken
private readonly accessToken: AccessToken
private readonly buscaUsuarioSessaoPorHashUseCase: BuscaUsuarioSessaoPorHashUseCase
private readonly apagaUsuarioSessaoUseCase: ApagaUsuarioSessaoUseCase
private readonly apagaUsuarioSessoesUseCase: ApagaUsuarioSessoesUseCase

constructor(dependencies: Dependencies) {
this.refreshToken = dependencies.refreshToken
this.accessToken = dependencies.accessToken
this.buscaUsuarioSessaoPorHashUseCase = dependencies.buscaUsuarioSessaoPorHashUseCase
this.apagaUsuarioSessaoUseCase = dependencies.apagaUsuarioSessaoUseCase
this.apagaUsuarioSessoesUseCase = dependencies.apagaUsuarioSessoesUseCase
}

async handle(request: HttpRequest, _next: NextHandler): Promise<HttpResponse | HttpError> {
const refresh = resolveRefreshToken(request)
const access = readBearerAccess(request)
const verifiedAccess = access ? this.accessToken.verify(access) : undefined

if (
refresh?.cookieOnly
&& looksLikeBrowserRequest(request)
&& !hasCsrfHeader(request)
&& !verifiedAccess?.right()
) {
return notAuthorized()
}

if (logoutAllRequested(request.body)) {
if (!verifiedAccess || verifiedAccess.left()) {
return this.cleared(notAuthorized())
}

const deletedAll = await this.apagaUsuarioSessoesUseCase.execute({
usuarioId: verifiedAccess.value.sub
})
if (deletedAll.left()) {
return new InternalServerError({ message: deletedAll.value.message })
}

return this.cleared({ statusCode: StatusCode.NoContent })
}

if (refresh) {
const hashed = this.refreshToken.hash(refresh.token)
if (hashed.left()) {
return this.cleared(notAuthorized())
}

const found = await this.buscaUsuarioSessaoPorHashUseCase.execute({
refreshTokenHash: hashed.value
})
if (found.left()) {
return new InternalServerError({ message: found.value.message })
}
if (found.value) {
const deleted = await this.apagaUsuarioSessaoUseCase.execute({ id: found.value.id })
if (deleted.left()) {
return new InternalServerError({ message: deleted.value.message })
}
}

return this.cleared({ statusCode: StatusCode.NoContent })
}

if (!verifiedAccess || verifiedAccess.left()) {
return this.cleared(notAuthorized())
}

const deleted = await this.apagaUsuarioSessaoUseCase.execute({ id: verifiedAccess.value.sid })
if (deleted.left()) {
return new InternalServerError({ message: deleted.value.message })
}

return this.cleared({ statusCode: StatusCode.NoContent })
}

private cleared(response: HttpResponse | HttpError): HttpResponse | HttpError {
if (response instanceof HttpError) {
return response
}

return {
...response,
headers: {
...response.headers,
'Set-Cookie': serializeClearedRefreshCookie()
}
}
}
}
43 changes: 43 additions & 0 deletions src/application/usuarioSessao/EntraSessaoController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { InvalidCredentialsError } from '@/domain/usuario/error/InvalidCredentialsError'
import { type EntraSessaoUseCase } from '@/domain/usuarioSessao/EntraSessaoUseCase'
import {
HttpRequest, HttpResponse, StatusCode
} from '@/library/http/common'
import { HttpError } from '@/library/http/error/HttpError'
import { InternalServerError } from '@/library/http/error/InternalServerError'
import { type NextHandler, type RequestHandler } from '@/library/http/Server'

import { serializeRefreshCookie } from './refreshCookie'
import { credenciaisInvalidas, sessaoResponseBody } from './sessaoHttp'

interface Dependencies {
entraSessaoUseCase: EntraSessaoUseCase
}

export class EntraSessaoController implements RequestHandler {
private readonly entraSessaoUseCase: EntraSessaoUseCase

constructor(dependencies: Dependencies) {
this.entraSessaoUseCase = dependencies.entraSessaoUseCase
}

async handle(request: HttpRequest, _next: NextHandler): Promise<HttpResponse | HttpError> {
const body = request.body as { email?: unknown; senha?: unknown }
const email = typeof body?.email === 'string' ? body.email : ''
const senha = typeof body?.senha === 'string' ? body.senha : ''

const result = await this.entraSessaoUseCase.execute({ email, senha })
if (result.left()) {
if (result.value instanceof InvalidCredentialsError) {
return credenciaisInvalidas()
}
return new InternalServerError({ message: result.value.message })
}

return {
statusCode: StatusCode.Ok,
headers: { 'Set-Cookie': serializeRefreshCookie(result.value.refreshToken) },
body: sessaoResponseBody(result.value)
}
}
}
44 changes: 44 additions & 0 deletions src/application/usuarioSessao/MostraSessaoController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { UserSessionNotFoundError } from '@/domain/usuarioSessao/error/UserSessionNotFoundError'
import { type MostraSessaoUseCase } from '@/domain/usuarioSessao/MostraSessaoUseCase'
import {
HttpRequest, HttpResponse, StatusCode
} from '@/library/http/common'
import { HttpError } from '@/library/http/error/HttpError'
import { InternalServerError } from '@/library/http/error/InternalServerError'
import { type NextHandler, type RequestHandler } from '@/library/http/Server'

import {
meResponseBody, notAuthorized, readBearerAccess
} from './sessaoHttp'

interface Dependencies {
mostraSessaoUseCase: MostraSessaoUseCase
}

export class MostraSessaoController implements RequestHandler {
private readonly mostraSessaoUseCase: MostraSessaoUseCase

constructor(dependencies: Dependencies) {
this.mostraSessaoUseCase = dependencies.mostraSessaoUseCase
}

async handle(request: HttpRequest, _next: NextHandler): Promise<HttpResponse | HttpError> {
const token = readBearerAccess(request)
if (!token) {
return notAuthorized()
}

const result = await this.mostraSessaoUseCase.execute({ accessToken: token })
if (result.left()) {
if (result.value instanceof UserSessionNotFoundError) {
return notAuthorized()
}
return new InternalServerError({ message: result.value.message })
}

return {
statusCode: StatusCode.Ok,
body: meResponseBody(result.value)
}
}
}
Loading
Loading