From 57cb1d9a26fb3cb930fc10d154d21c0654783a02 Mon Sep 17 00:00:00 2001 From: Edvaldo Szymonek Date: Sun, 27 Sep 2026 16:23:29 -0300 Subject: [PATCH 1/4] =?UTF-8?q?implementa=20rota=20de=20login,=20refresh,?= =?UTF-8?q?=20logout=20e=20sess=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 +- README.md | 2 +- package.json | 1 + src/application/RateLimitMiddleware.ts | 67 +++++ src/application/create-app.ts | 11 +- src/application/index.ts | 7 +- src/application/parseCorsOrigins.ts | 24 ++ .../usuarioSessao/EncerraSessaoController.ts | 122 +++++++++ .../usuarioSessao/EntraSessaoController.ts | 43 +++ .../usuarioSessao/MostraSessaoController.ts | 44 ++++ .../usuarioSessao/RenovaSessaoController.ts | 49 ++++ src/application/usuarioSessao/index.ts | 111 ++++++++ .../usuarioSessao/refreshCookie.ts | 21 ++ src/application/usuarioSessao/sessaoHttp.ts | 119 +++++++++ src/domain/usuario/Usuario.ts | 44 ++++ src/domain/usuario/UsuarioCollection.ts | 8 + .../error/CredenciaisInvalidasError.ts | 10 + .../usuarioSessao/EntraSessaoUseCase.ts | 62 +++++ .../usuarioSessao/MostraSessaoUseCase.ts | 50 ++++ .../usuarioSessao/RenovaSessaoUseCase.ts | 61 +++++ src/domain/usuarioSessao/UsuarioSessao.ts | 2 +- src/domain/usuarioSessao/sessaoAutenticada.ts | 7 + src/factory/AccessTokenFactory.ts | 6 + src/factory/RateLimiterFactory.ts | 10 + src/factory/RefreshTokenFactory.ts | 6 + src/infrastructure/ExpressApplication.ts | 15 +- .../UsuarioCollectionKnexAdapter.ts | 120 +++++++++ src/infrastructure/auth/JwtAccessToken.ts | 9 +- src/library/auth/AccessToken.ts | 3 +- src/library/http/common.ts | 3 + .../http/error/TooManyRequestsError.ts | 7 + src/library/http/parseCookieHeader.ts | 28 ++ test/integration/setup/app-factory.ts | 6 +- test/integration/setup/schema.sql | 18 ++ .../usuarioSessao/sessao-http.test.ts | 249 ++++++++++++++++++ .../application/RateLimitMiddleware.test.ts | 73 +++++ .../unit/application/parseCorsOrigins.test.ts | 27 ++ .../usuarioSessao/refreshCookie.test.ts | 41 +++ .../domain/usuario/FakeUsuarioCollection.ts | 32 +++ .../usuarioSessao/EntraSessaoUseCase.test.ts | 104 ++++++++ .../UsuarioCollectionKnexAdapter.test.ts | 82 ++++++ .../auth/JwtAccessToken.test.ts | 16 +- .../library/http/parseCookieHeader.test.ts | 18 ++ yarn.lock | 5 + 44 files changed, 1715 insertions(+), 34 deletions(-) create mode 100644 src/application/RateLimitMiddleware.ts create mode 100644 src/application/parseCorsOrigins.ts create mode 100644 src/application/usuarioSessao/EncerraSessaoController.ts create mode 100644 src/application/usuarioSessao/EntraSessaoController.ts create mode 100644 src/application/usuarioSessao/MostraSessaoController.ts create mode 100644 src/application/usuarioSessao/RenovaSessaoController.ts create mode 100644 src/application/usuarioSessao/index.ts create mode 100644 src/application/usuarioSessao/refreshCookie.ts create mode 100644 src/application/usuarioSessao/sessaoHttp.ts create mode 100644 src/domain/usuario/Usuario.ts create mode 100644 src/domain/usuario/UsuarioCollection.ts create mode 100644 src/domain/usuario/error/CredenciaisInvalidasError.ts create mode 100644 src/domain/usuarioSessao/EntraSessaoUseCase.ts create mode 100644 src/domain/usuarioSessao/MostraSessaoUseCase.ts create mode 100644 src/domain/usuarioSessao/RenovaSessaoUseCase.ts create mode 100644 src/domain/usuarioSessao/sessaoAutenticada.ts create mode 100644 src/factory/AccessTokenFactory.ts create mode 100644 src/factory/RateLimiterFactory.ts create mode 100644 src/factory/RefreshTokenFactory.ts create mode 100644 src/infrastructure/UsuarioCollectionKnexAdapter.ts create mode 100644 src/library/http/error/TooManyRequestsError.ts create mode 100644 src/library/http/parseCookieHeader.ts create mode 100644 test/integration/usuarioSessao/sessao-http.test.ts create mode 100644 test/unit/application/RateLimitMiddleware.test.ts create mode 100644 test/unit/application/parseCorsOrigins.test.ts create mode 100644 test/unit/application/usuarioSessao/refreshCookie.test.ts create mode 100644 test/unit/domain/usuario/FakeUsuarioCollection.ts create mode 100644 test/unit/domain/usuarioSessao/EntraSessaoUseCase.test.ts create mode 100644 test/unit/infrastructure/UsuarioCollectionKnexAdapter.test.ts create mode 100644 test/unit/library/http/parseCookieHeader.test.ts diff --git a/.env.example b/.env.example index dbbd176a..6f3f9f70 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index dcf9bf68..c14aca80 100644 --- a/README.md +++ b/README.md @@ -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`. | diff --git a/package.json b/package.json index dd5aed02..a9f13b38 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/application/RateLimitMiddleware.ts b/src/application/RateLimitMiddleware.ts new file mode 100644 index 00000000..9c97aad9 --- /dev/null +++ b/src/application/RateLimitMiddleware.ts @@ -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 +} + +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 { + 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 +} diff --git a/src/application/create-app.ts b/src/application/create-app.ts index 90c7b3ae..9f45731c 100644 --- a/src/application/create-app.ts +++ b/src/application/create-app.ts @@ -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 { @@ -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 })) diff --git a/src/application/index.ts b/src/application/index.ts index 59c82f39..0ca37442 100644 --- a/src/application/index.ts +++ b/src/application/index.ts @@ -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({ diff --git a/src/application/parseCorsOrigins.ts b/src/application/parseCorsOrigins.ts new file mode 100644 index 00000000..e8f677fe --- /dev/null +++ b/src/application/parseCorsOrigins.ts @@ -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 +} diff --git a/src/application/usuarioSessao/EncerraSessaoController.ts b/src/application/usuarioSessao/EncerraSessaoController.ts new file mode 100644 index 00000000..37f19220 --- /dev/null +++ b/src/application/usuarioSessao/EncerraSessaoController.ts @@ -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, + naoAutorizado, + 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 { + 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 naoAutorizado() + } + + if (logoutAllRequested(request.body)) { + if (!verifiedAccess || verifiedAccess.left()) { + return this.cleared(naoAutorizado()) + } + + 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(naoAutorizado()) + } + + 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(naoAutorizado()) + } + + 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() + } + } + } +} diff --git a/src/application/usuarioSessao/EntraSessaoController.ts b/src/application/usuarioSessao/EntraSessaoController.ts new file mode 100644 index 00000000..3cf26069 --- /dev/null +++ b/src/application/usuarioSessao/EntraSessaoController.ts @@ -0,0 +1,43 @@ +import { CredenciaisInvalidasError } from '@/domain/usuario/error/CredenciaisInvalidasError' +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 { + 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 CredenciaisInvalidasError) { + return credenciaisInvalidas() + } + return new InternalServerError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.Ok, + headers: { 'Set-Cookie': serializeRefreshCookie(result.value.refreshToken) }, + body: sessaoResponseBody(result.value) + } + } +} diff --git a/src/application/usuarioSessao/MostraSessaoController.ts b/src/application/usuarioSessao/MostraSessaoController.ts new file mode 100644 index 00000000..3f30c12b --- /dev/null +++ b/src/application/usuarioSessao/MostraSessaoController.ts @@ -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, naoAutorizado, 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 { + const token = readBearerAccess(request) + if (!token) { + return naoAutorizado() + } + + const result = await this.mostraSessaoUseCase.execute({ accessToken: token }) + if (result.left()) { + if (result.value instanceof UserSessionNotFoundError) { + return naoAutorizado() + } + return new InternalServerError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.Ok, + body: meResponseBody(result.value) + } + } +} diff --git a/src/application/usuarioSessao/RenovaSessaoController.ts b/src/application/usuarioSessao/RenovaSessaoController.ts new file mode 100644 index 00000000..9f90098b --- /dev/null +++ b/src/application/usuarioSessao/RenovaSessaoController.ts @@ -0,0 +1,49 @@ +import { UserSessionNotFoundError } from '@/domain/usuarioSessao/error/UserSessionNotFoundError' +import { type RenovaSessaoUseCase } from '@/domain/usuarioSessao/RenovaSessaoUseCase' +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 { + hasCsrfHeader, looksLikeBrowserRequest, naoAutorizado, resolveRefreshToken, sessaoResponseBody +} from './sessaoHttp' + +interface Dependencies { + renovaSessaoUseCase: RenovaSessaoUseCase +} + +export class RenovaSessaoController implements RequestHandler { + private readonly renovaSessaoUseCase: RenovaSessaoUseCase + + constructor(dependencies: Dependencies) { + this.renovaSessaoUseCase = dependencies.renovaSessaoUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const refresh = resolveRefreshToken(request) + if (!refresh) { + return naoAutorizado() + } + if (refresh.cookieOnly && looksLikeBrowserRequest(request) && !hasCsrfHeader(request)) { + return naoAutorizado() + } + + const result = await this.renovaSessaoUseCase.execute({ refreshToken: refresh.token }) + if (result.left()) { + if (result.value instanceof UserSessionNotFoundError) { + return naoAutorizado() + } + return new InternalServerError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.Ok, + headers: { 'Set-Cookie': serializeRefreshCookie(result.value.refreshToken) }, + body: sessaoResponseBody(result.value) + } + } +} diff --git a/src/application/usuarioSessao/index.ts b/src/application/usuarioSessao/index.ts new file mode 100644 index 00000000..8d080df0 --- /dev/null +++ b/src/application/usuarioSessao/index.ts @@ -0,0 +1,111 @@ +import { type Knex } from 'knex' + +import { RateLimitMiddleware } from '@/application/RateLimitMiddleware' +import { ApagaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/ApagaUsuarioSessaoUseCase' +import { ApagaUsuarioSessoesUseCase } from '@/domain/usuarioSessao/ApagaUsuarioSessoesUseCase' +import { BuscaUsuarioSessaoPorHashUseCase } from '@/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase' +import { BuscaUsuarioSessaoPorIdUseCase } from '@/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase' +import { CriaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/CriaUsuarioSessaoUseCase' +import { EntraSessaoUseCase } from '@/domain/usuarioSessao/EntraSessaoUseCase' +import { MostraSessaoUseCase } from '@/domain/usuarioSessao/MostraSessaoUseCase' +import { RenovaSessaoUseCase } from '@/domain/usuarioSessao/RenovaSessaoUseCase' +import { RotacionaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase' +import { createAccessToken } from '@/factory/AccessTokenFactory' +import { createRateLimiter } from '@/factory/RateLimiterFactory' +import { createRefreshToken } from '@/factory/RefreshTokenFactory' +import { comparaSenha } from '@/helpers/senhas' +import { UsuarioCollectionKnexAdapter } from '@/infrastructure/UsuarioCollectionKnexAdapter' +import { UsuarioSessaoCollectionKnexAdapter } from '@/infrastructure/UsuarioSessaoCollectionKnexAdapter' +import { Method } from '@/library/http/common' +import { HttpError } from '@/library/http/error/HttpError' +import { Route } from '@/library/http/Router' + +import { EncerraSessaoController } from './EncerraSessaoController' +import { EntraSessaoController } from './EntraSessaoController' +import { MostraSessaoController } from './MostraSessaoController' +import { RenovaSessaoController } from './RenovaSessaoController' + +export function routes(knex: Knex): Route[] { + const usuarioCollection = new UsuarioCollectionKnexAdapter({ knex }) + const usuarioSessaoCollection = new UsuarioSessaoCollectionKnexAdapter({ knex }) + const refreshToken = createRefreshToken() + const accessToken = createAccessToken() + + const criaUsuarioSessaoUseCase = new CriaUsuarioSessaoUseCase({ + usuarioSessaoCollection, + refreshToken + }) + const rotacionaUsuarioSessaoUseCase = new RotacionaUsuarioSessaoUseCase({ + usuarioSessaoCollection, + refreshToken + }) + const apagaUsuarioSessaoUseCase = new ApagaUsuarioSessaoUseCase({ usuarioSessaoCollection }) + const apagaUsuarioSessoesUseCase = new ApagaUsuarioSessoesUseCase({ usuarioSessaoCollection }) + const buscaUsuarioSessaoPorIdUseCase = new BuscaUsuarioSessaoPorIdUseCase({ + usuarioSessaoCollection + }) + const buscaUsuarioSessaoPorHashUseCase = new BuscaUsuarioSessaoPorHashUseCase({ + usuarioSessaoCollection + }) + + return [ + { + method: Method.Post, + path: '/auth/login', + handlers: [ + new RateLimitMiddleware({ + limiter: createRateLimiter(), + isFailure: response => response instanceof HttpError && response.statusCode === 401 + }), + new EntraSessaoController({ + entraSessaoUseCase: new EntraSessaoUseCase({ + usuarioCollection, + criaUsuarioSessaoUseCase, + accessToken, + comparaSenha + }) + }) + ] + }, + { + method: Method.Post, + path: '/auth/refresh', + handlers: [ + new RenovaSessaoController({ + renovaSessaoUseCase: new RenovaSessaoUseCase({ + usuarioCollection, + rotacionaUsuarioSessaoUseCase, + apagaUsuarioSessaoUseCase, + accessToken + }) + }) + ] + }, + { + method: Method.Post, + path: '/auth/logout', + handlers: [ + new EncerraSessaoController({ + refreshToken, + accessToken, + buscaUsuarioSessaoPorHashUseCase, + apagaUsuarioSessaoUseCase, + apagaUsuarioSessoesUseCase + }) + ] + }, + { + method: Method.Get, + path: '/auth/me', + handlers: [ + new MostraSessaoController({ + mostraSessaoUseCase: new MostraSessaoUseCase({ + accessToken, + usuarioCollection, + buscaUsuarioSessaoPorIdUseCase + }) + }) + ] + } + ] +} diff --git a/src/application/usuarioSessao/refreshCookie.ts b/src/application/usuarioSessao/refreshCookie.ts new file mode 100644 index 00000000..babf9b80 --- /dev/null +++ b/src/application/usuarioSessao/refreshCookie.ts @@ -0,0 +1,21 @@ +import { UsuarioSessao } from '@/domain/usuarioSessao/UsuarioSessao' + +export const REFRESH_COOKIE_NAME = 'refresh_token' +const REFRESH_COOKIE_PATH = '/api/auth' +const MAX_AGE_SECONDS = UsuarioSessao.REFRESH_TTL_DAYS * 24 * 60 * 60 + +function cookieFlags(): string { + // SameSite=None requires Secure; Postman on http://localhost will not send Secure cookies. + if (process.env.NODE_ENV === 'production') { + return `Path=${REFRESH_COOKIE_PATH}; HttpOnly; SameSite=None; Secure` + } + return `Path=${REFRESH_COOKIE_PATH}; HttpOnly; SameSite=Lax` +} + +export function serializeRefreshCookie(token: string): string { + return `${REFRESH_COOKIE_NAME}=${encodeURIComponent(token)}; Max-Age=${MAX_AGE_SECONDS}; ${cookieFlags()}` +} + +export function serializeClearedRefreshCookie(): string { + return `${REFRESH_COOKIE_NAME}=; Max-Age=0; ${cookieFlags()}` +} diff --git a/src/application/usuarioSessao/sessaoHttp.ts b/src/application/usuarioSessao/sessaoHttp.ts new file mode 100644 index 00000000..aa106e40 --- /dev/null +++ b/src/application/usuarioSessao/sessaoHttp.ts @@ -0,0 +1,119 @@ +import { type Attributes } from '@/domain/usuario/Usuario' +import { createRules } from '@/library/auth/createRules' +import { type HttpRequest } from '@/library/http/common' +import { UnauthorizedError } from '@/library/http/error/UnauthorizedError' + +import { REFRESH_COOKIE_NAME } from './refreshCookie' + +export const ACCESS_EXPIRES_IN_SECONDS = 900 + +export interface SessaoUsuario { + id: number + nome: string + email: string + tipo_usuario_id: number +} + +export function credenciaisInvalidas(): UnauthorizedError { + return new UnauthorizedError({ message: 'Credenciais inválidas' }) +} + +export function naoAutorizado(): UnauthorizedError { + return new UnauthorizedError({ message: 'Não autorizado' }) +} + +export function toSessaoUsuario(user: Attributes): SessaoUsuario { + return { + id: user.id, + nome: user.nome, + email: user.email, + tipo_usuario_id: user.tipoUsuarioId + } +} + +export function sessaoResponseBody(params: { + accessToken: string + refreshToken: string + user: Attributes +}) { + const user = toSessaoUsuario(params.user) + return { + access_token: params.accessToken, + refresh_token: params.refreshToken, + token_type: 'Bearer', + expires_in: ACCESS_EXPIRES_IN_SECONDS, + user, + rules: createRules({ + id: user.id, + tipo_usuario_id: user.tipo_usuario_id + }) + } +} + +export function meResponseBody(user: Attributes) { + const dto = toSessaoUsuario(user) + return { + user: dto, + rules: createRules({ + id: dto.id, + tipo_usuario_id: dto.tipo_usuario_id + }) + } +} + +export function readRefreshFromBody(body: unknown): string | undefined { + if (body === null || typeof body !== 'object') { + return undefined + } + + const record = body as { refresh_token?: unknown; refreshToken?: unknown } + const token = record.refresh_token ?? record.refreshToken + return typeof token === 'string' && token.length > 0 ? token : undefined +} + +export function resolveRefreshToken(request: HttpRequest): { + token: string + cookieOnly: boolean +} | undefined { + const fromBody = readRefreshFromBody(request.body) + if (fromBody) { + return { token: fromBody, cookieOnly: false } + } + + const fromCookie = request.cookies?.[REFRESH_COOKIE_NAME] + if (typeof fromCookie === 'string' && fromCookie.length > 0) { + return { token: fromCookie, cookieOnly: true } + } + + return undefined +} + +export function hasCsrfHeader(request: HttpRequest): boolean { + const value = request.headers['x-requested-with'] + return typeof value === 'string' && value.trim().length > 0 +} + +export function looksLikeBrowserRequest(request: HttpRequest): boolean { + const origin = request.headers.origin + const referer = request.headers.referer + return (typeof origin === 'string' && origin.length > 0) + || (typeof referer === 'string' && referer.length > 0) +} + +export function readBearerAccess(request: HttpRequest): string | undefined { + const value = request.headers.authorization ?? request.headers.Authorization + if (typeof value !== 'string') { + return undefined + } + + const match = /^Bearer\s+(\S+)$/i.exec(value.trim()) + return match?.[1] +} + +export function logoutAllRequested(body: unknown): boolean { + if (body === null || typeof body !== 'object') { + return false + } + + return (body as { all?: unknown }).all === true +} diff --git a/src/domain/usuario/Usuario.ts b/src/domain/usuario/Usuario.ts new file mode 100644 index 00000000..0dd10cbc --- /dev/null +++ b/src/domain/usuario/Usuario.ts @@ -0,0 +1,44 @@ +import { Either } from '@/library/either/Either' + +export interface Attributes { + id: number + nome: string + email: string + tipoUsuarioId: number +} + +export interface AttributesComSenha extends Attributes { + senha: string +} + +export class Usuario { + readonly id: number + readonly nome: string + readonly email: string + readonly tipoUsuarioId: number + readonly senha?: string + + private constructor(attributes: Attributes | AttributesComSenha) { + this.id = attributes.id + this.nome = attributes.nome + this.email = attributes.email + this.tipoUsuarioId = attributes.tipoUsuarioId + if ('senha' in attributes) { + this.senha = attributes.senha + } + } + + static create(attributes: Attributes | AttributesComSenha): Either { + if (!Number.isInteger(attributes.id) || attributes.id <= 0) { + return Either.left(new Error('id do usuário deve ser um inteiro positivo')) + } + if (!attributes.email.trim()) { + return Either.left(new Error('email do usuário não pode ser vazio')) + } + if (!Number.isInteger(attributes.tipoUsuarioId) || attributes.tipoUsuarioId <= 0) { + return Either.left(new Error('tipoUsuarioId deve ser um inteiro positivo')) + } + + return Either.right(new Usuario(attributes)) + } +} diff --git a/src/domain/usuario/UsuarioCollection.ts b/src/domain/usuario/UsuarioCollection.ts new file mode 100644 index 00000000..9dc66ef6 --- /dev/null +++ b/src/domain/usuario/UsuarioCollection.ts @@ -0,0 +1,8 @@ +import { type Either } from '@/library/either/Either' + +import { type Attributes, type AttributesComSenha } from './Usuario' + +export interface UsuarioCollection { + findByEmail(email: string): Promise> + findById(id: number): Promise> +} diff --git a/src/domain/usuario/error/CredenciaisInvalidasError.ts b/src/domain/usuario/error/CredenciaisInvalidasError.ts new file mode 100644 index 00000000..b6095979 --- /dev/null +++ b/src/domain/usuario/error/CredenciaisInvalidasError.ts @@ -0,0 +1,10 @@ +import { BaseError } from '@/library/BaseError' + +export class CredenciaisInvalidasError extends BaseError { + constructor(params?: { cause?: unknown }) { + super({ + message: 'Credenciais inválidas', + cause: params?.cause + }) + } +} diff --git a/src/domain/usuarioSessao/EntraSessaoUseCase.ts b/src/domain/usuarioSessao/EntraSessaoUseCase.ts new file mode 100644 index 00000000..71eff641 --- /dev/null +++ b/src/domain/usuarioSessao/EntraSessaoUseCase.ts @@ -0,0 +1,62 @@ +import { CredenciaisInvalidasError } from '@/domain/usuario/error/CredenciaisInvalidasError' +import { type UsuarioCollection } from '@/domain/usuario/UsuarioCollection' +import { type AccessToken } from '@/library/auth/AccessToken' +import { Either } from '@/library/either/Either' + +import { type CriaUsuarioSessaoUseCase } from './CriaUsuarioSessaoUseCase' +import { type SessaoAutenticada } from './sessaoAutenticada' + +interface Dependencies { + usuarioCollection: UsuarioCollection + criaUsuarioSessaoUseCase: CriaUsuarioSessaoUseCase + accessToken: AccessToken + comparaSenha: (texto: string, hash: string) => boolean +} + +export class EntraSessaoUseCase { + private readonly usuarioCollection: UsuarioCollection + private readonly criaUsuarioSessaoUseCase: CriaUsuarioSessaoUseCase + private readonly accessToken: AccessToken + private readonly comparaSenha: Dependencies['comparaSenha'] + + constructor(dependencies: Dependencies) { + this.usuarioCollection = dependencies.usuarioCollection + this.criaUsuarioSessaoUseCase = dependencies.criaUsuarioSessaoUseCase + this.accessToken = dependencies.accessToken + this.comparaSenha = dependencies.comparaSenha + } + + async execute(params: { email: string; senha: string }): Promise> { + const found = await this.usuarioCollection.findByEmail(params.email) + if (found.left()) { + return found + } + if (!found.value || !this.comparaSenha(params.senha, found.value.senha)) { + return Either.left(new CredenciaisInvalidasError()) + } + + const created = await this.criaUsuarioSessaoUseCase.execute({ usuarioId: found.value.id }) + if (created.left()) { + return created + } + + const signed = this.accessToken.sign({ + sub: found.value.id, + sid: created.value.session.id + }) + if (signed.left()) { + return signed + } + + return Either.right({ + accessToken: signed.value, + refreshToken: created.value.refreshToken, + user: { + id: found.value.id, + nome: found.value.nome, + email: found.value.email, + tipoUsuarioId: found.value.tipoUsuarioId + } + }) + } +} diff --git a/src/domain/usuarioSessao/MostraSessaoUseCase.ts b/src/domain/usuarioSessao/MostraSessaoUseCase.ts new file mode 100644 index 00000000..dcb264d7 --- /dev/null +++ b/src/domain/usuarioSessao/MostraSessaoUseCase.ts @@ -0,0 +1,50 @@ +import { type Attributes } from '@/domain/usuario/Usuario' +import { type UsuarioCollection } from '@/domain/usuario/UsuarioCollection' +import { type AccessToken } from '@/library/auth/AccessToken' +import { Either } from '@/library/either/Either' + +import { type BuscaUsuarioSessaoPorIdUseCase } from './BuscaUsuarioSessaoPorIdUseCase' +import { UserSessionNotFoundError } from './error/UserSessionNotFoundError' + +interface Dependencies { + accessToken: AccessToken + usuarioCollection: UsuarioCollection + buscaUsuarioSessaoPorIdUseCase: BuscaUsuarioSessaoPorIdUseCase +} + +export class MostraSessaoUseCase { + private readonly accessToken: AccessToken + private readonly usuarioCollection: UsuarioCollection + private readonly buscaUsuarioSessaoPorIdUseCase: BuscaUsuarioSessaoPorIdUseCase + + constructor(dependencies: Dependencies) { + this.accessToken = dependencies.accessToken + this.usuarioCollection = dependencies.usuarioCollection + this.buscaUsuarioSessaoPorIdUseCase = dependencies.buscaUsuarioSessaoPorIdUseCase + } + + async execute(params: { accessToken: string }): Promise> { + const verified = this.accessToken.verify(params.accessToken) + if (verified.left()) { + return Either.left(new UserSessionNotFoundError({ cause: verified.value })) + } + + const session = await this.buscaUsuarioSessaoPorIdUseCase.execute({ id: verified.value.sid }) + if (session.left()) { + return session + } + if (!session.value) { + return Either.left(new UserSessionNotFoundError()) + } + + const usuario = await this.usuarioCollection.findById(verified.value.sub) + if (usuario.left()) { + return usuario + } + if (!usuario.value) { + return Either.left(new UserSessionNotFoundError()) + } + + return Either.right(usuario.value) + } +} diff --git a/src/domain/usuarioSessao/RenovaSessaoUseCase.ts b/src/domain/usuarioSessao/RenovaSessaoUseCase.ts new file mode 100644 index 00000000..d69a0b60 --- /dev/null +++ b/src/domain/usuarioSessao/RenovaSessaoUseCase.ts @@ -0,0 +1,61 @@ +import { type UsuarioCollection } from '@/domain/usuario/UsuarioCollection' +import { type AccessToken } from '@/library/auth/AccessToken' +import { Either } from '@/library/either/Either' + +import { type ApagaUsuarioSessaoUseCase } from './ApagaUsuarioSessaoUseCase' +import { UserSessionNotFoundError } from './error/UserSessionNotFoundError' +import { type RotacionaUsuarioSessaoUseCase } from './RotacionaUsuarioSessaoUseCase' +import { type SessaoAutenticada } from './sessaoAutenticada' + +interface Dependencies { + usuarioCollection: UsuarioCollection + rotacionaUsuarioSessaoUseCase: RotacionaUsuarioSessaoUseCase + apagaUsuarioSessaoUseCase: ApagaUsuarioSessaoUseCase + accessToken: AccessToken +} + +export class RenovaSessaoUseCase { + private readonly usuarioCollection: UsuarioCollection + private readonly rotacionaUsuarioSessaoUseCase: RotacionaUsuarioSessaoUseCase + private readonly apagaUsuarioSessaoUseCase: ApagaUsuarioSessaoUseCase + private readonly accessToken: AccessToken + + constructor(dependencies: Dependencies) { + this.usuarioCollection = dependencies.usuarioCollection + this.rotacionaUsuarioSessaoUseCase = dependencies.rotacionaUsuarioSessaoUseCase + this.apagaUsuarioSessaoUseCase = dependencies.apagaUsuarioSessaoUseCase + this.accessToken = dependencies.accessToken + } + + async execute(params: { refreshToken: string }): Promise> { + const rotated = await this.rotacionaUsuarioSessaoUseCase.execute({ + refreshToken: params.refreshToken + }) + if (rotated.left()) { + return rotated + } + + const found = await this.usuarioCollection.findById(rotated.value.session.usuarioId) + if (found.left()) { + return found + } + if (!found.value) { + await this.apagaUsuarioSessaoUseCase.execute({ id: rotated.value.session.id }) + return Either.left(new UserSessionNotFoundError()) + } + + const signed = this.accessToken.sign({ + sub: found.value.id, + sid: rotated.value.session.id + }) + if (signed.left()) { + return signed + } + + return Either.right({ + accessToken: signed.value, + refreshToken: rotated.value.refreshToken, + user: found.value + }) + } +} diff --git a/src/domain/usuarioSessao/UsuarioSessao.ts b/src/domain/usuarioSessao/UsuarioSessao.ts index 7e64eb7a..6764dd7e 100644 --- a/src/domain/usuarioSessao/UsuarioSessao.ts +++ b/src/domain/usuarioSessao/UsuarioSessao.ts @@ -45,7 +45,7 @@ export class UsuarioSessao { return Either.left(new Error('Id da sessão deve ser um UUID')) } - if (!Number.isInteger(attributes.usuarioId) || attributes.usuarioId <= 0) { + if (!Number.isInteger(Number(attributes.usuarioId)) || attributes.usuarioId <= 0) { return Either.left(new Error('usuarioId da sessão deve ser um inteiro positivo')) } diff --git a/src/domain/usuarioSessao/sessaoAutenticada.ts b/src/domain/usuarioSessao/sessaoAutenticada.ts new file mode 100644 index 00000000..5c7ef8ac --- /dev/null +++ b/src/domain/usuarioSessao/sessaoAutenticada.ts @@ -0,0 +1,7 @@ +import { type Attributes } from '@/domain/usuario/Usuario' + +export interface SessaoAutenticada { + accessToken: string + refreshToken: string + user: Attributes +} diff --git a/src/factory/AccessTokenFactory.ts b/src/factory/AccessTokenFactory.ts new file mode 100644 index 00000000..c31f7cfe --- /dev/null +++ b/src/factory/AccessTokenFactory.ts @@ -0,0 +1,6 @@ +import { JwtAccessToken } from '@/infrastructure/auth/JwtAccessToken' +import { singleton } from '@/library/singleton' + +export const createAccessToken = singleton(() => { + return new JwtAccessToken({ secret: process.env.JWT_SECRET ?? '' }) +}) diff --git a/src/factory/RateLimiterFactory.ts b/src/factory/RateLimiterFactory.ts new file mode 100644 index 00000000..07c0c0fa --- /dev/null +++ b/src/factory/RateLimiterFactory.ts @@ -0,0 +1,10 @@ +import { RateLimiterMemory } from 'rate-limiter-flexible' + +import { singleton } from '@/library/singleton' + +export const createRateLimiter = singleton(() => { + return new RateLimiterMemory({ + points: 5, + duration: 15 * 60 + }) +}) diff --git a/src/factory/RefreshTokenFactory.ts b/src/factory/RefreshTokenFactory.ts new file mode 100644 index 00000000..a6ad4bc0 --- /dev/null +++ b/src/factory/RefreshTokenFactory.ts @@ -0,0 +1,6 @@ +import { CryptoRefreshToken } from '@/infrastructure/auth/CryptoRefreshToken' +import { singleton } from '@/library/singleton' + +export const createRefreshToken = singleton(() => { + return new CryptoRefreshToken() +}) diff --git a/src/infrastructure/ExpressApplication.ts b/src/infrastructure/ExpressApplication.ts index 89e8b310..aff748e8 100644 --- a/src/infrastructure/ExpressApplication.ts +++ b/src/infrastructure/ExpressApplication.ts @@ -4,10 +4,11 @@ import http from 'node:http' import { Application } from '@/library/Application' import { - Headers, HttpRequest, HttpResponse, Method + Headers, HttpRequest, HttpResponse, Method, StatusCode } from '@/library/http/common' import { HttpError } from '@/library/http/error/HttpError' import { InternalServerError } from '@/library/http/error/InternalServerError' +import { parseCookieHeader } from '@/library/http/parseCookieHeader' import { RequestHandler } from '@/library/http/Server' import { Logger } from '@/library/logger/Logger' @@ -24,6 +25,7 @@ export class ExpressApplication implements Application { constructor({ logger }: Dependencies) { this.app = express() this.app.use(parser.json()) + this.app.use(parser.urlencoded({ extended: false })) this.logger = logger this.server = http.createServer(this.app) @@ -54,6 +56,7 @@ export class ExpressApplication implements Application { method, path: expressRequest.path, headers, + cookies: parseCookieHeader(expressRequest.headers.cookie), params, body: expressRequest.body } @@ -141,6 +144,16 @@ export class ExpressApplication implements Application { return } + const setCookie = response.headers?.['Set-Cookie'] + if (typeof setCookie === 'string' && setCookie.length > 0) { + expressResponse.setHeader('Set-Cookie', setCookie) + } + + if (response.statusCode === StatusCode.NoContent) { + expressResponse.status(StatusCode.NoContent).end() + return + } + const contentType = response.headers?.['Content-Type'] ?? 'application/json' const body = response.body diff --git a/src/infrastructure/UsuarioCollectionKnexAdapter.ts b/src/infrastructure/UsuarioCollectionKnexAdapter.ts new file mode 100644 index 00000000..1eed6153 --- /dev/null +++ b/src/infrastructure/UsuarioCollectionKnexAdapter.ts @@ -0,0 +1,120 @@ +import { type Knex } from 'knex' + +import { + type Attributes, type AttributesComSenha, Usuario +} from '@/domain/usuario/Usuario' +import { type UsuarioCollection } from '@/domain/usuario/UsuarioCollection' +import { Either } from '@/library/either/Either' + +import { CollectionError } from './error/CollectionError' + +interface Dependencies { + knex: Knex +} + +interface UsuarioRow { + id: number | string + nome: string + email: string + senha?: string + tipo_usuario_id: number | string +} + +export class UsuarioCollectionKnexAdapter implements UsuarioCollection { + private readonly knex: Knex + + constructor(dependencies: Dependencies) { + this.knex = dependencies.knex + } + + async findByEmail(email: string): Promise> { + try { + const row = await this.knex('usuarios') + .select([ + 'id', + 'nome', + 'email', + 'senha', + 'tipo_usuario_id' + ]) + .where({ email }) + .first() + + if (!row || row.senha === undefined) { + return Either.right(null) + } + + return this.toComSenha(row) + } catch (error) { + return Either.left(new CollectionError({ + message: 'Failed to find usuário by email', + cause: error + })) + } + } + + async findById(id: number): Promise> { + try { + const row = await this.knex('usuarios') + .select([ + 'id', + 'nome', + 'email', + 'tipo_usuario_id' + ]) + .where({ id }) + .first() + + if (!row) { + return Either.right(null) + } + + return this.toAttributes(row) + } catch (error) { + return Either.left(new CollectionError({ + message: 'Failed to find usuário by id', + cause: error + })) + } + } + + private toAttributes(row: UsuarioRow): Either { + const created = Usuario.create({ + id: Number(row.id), + nome: row.nome, + email: row.email, + tipoUsuarioId: Number(row.tipo_usuario_id) + }) + if (created.left()) { + return created + } + + return Either.right({ + id: created.value.id, + nome: created.value.nome, + email: created.value.email, + tipoUsuarioId: created.value.tipoUsuarioId + }) + } + + private toComSenha(row: UsuarioRow): Either { + const created = Usuario.create({ + id: Number(row.id), + nome: row.nome, + email: row.email, + tipoUsuarioId: Number(row.tipo_usuario_id), + senha: row.senha as string + }) + if (created.left()) { + return created + } + + return Either.right({ + id: created.value.id, + nome: created.value.nome, + email: created.value.email, + tipoUsuarioId: created.value.tipoUsuarioId, + senha: created.value.senha as string + }) + } +} diff --git a/src/infrastructure/auth/JwtAccessToken.ts b/src/infrastructure/auth/JwtAccessToken.ts index 968ca247..cf748e1b 100644 --- a/src/infrastructure/auth/JwtAccessToken.ts +++ b/src/infrastructure/auth/JwtAccessToken.ts @@ -21,14 +21,13 @@ export class JwtAccessToken implements AccessToken { this.secret = params.secret } - sign(params: { sub: number; sid: string; role: number }): Either { + sign(params: { sub: number; sid: string }): Either { try { return Either.right(jwt.sign( { sub: String(params.sub), sid: params.sid, - typ: ACCESS_TYP, - role: params.role + typ: ACCESS_TYP }, this.secret, { @@ -72,7 +71,6 @@ export class JwtAccessToken implements AccessToken { const sub = Number(decoded.sub) const sid = claims.sid const typ = claims.typ - const role = claims.role const iat = claims.iat const exp = claims.exp @@ -81,8 +79,6 @@ export class JwtAccessToken implements AccessToken { || typeof sid !== 'string' || sid.length === 0 || typ !== ACCESS_TYP - || typeof role !== 'number' - || !Number.isInteger(role) || typeof iat !== 'number' || typeof exp !== 'number' ) { @@ -93,7 +89,6 @@ export class JwtAccessToken implements AccessToken { sub, sid, typ: ACCESS_TYP, - role, iat, exp } diff --git a/src/library/auth/AccessToken.ts b/src/library/auth/AccessToken.ts index 4cf157fc..92f1bce9 100644 --- a/src/library/auth/AccessToken.ts +++ b/src/library/auth/AccessToken.ts @@ -5,12 +5,11 @@ export interface AccessPayload { sub: number sid: string typ: 'access' - role: number iat: number exp: number } export interface AccessToken { - sign(params: { sub: number; sid: string; role: number }): Either + sign(params: { sub: number; sid: string }): Either verify(token: string): Either } diff --git a/src/library/http/common.ts b/src/library/http/common.ts index 8bda7739..54c0733d 100644 --- a/src/library/http/common.ts +++ b/src/library/http/common.ts @@ -19,6 +19,7 @@ export const StatusCode = { NotFound: 404, Conflict: 409, UnprocessableEntity: 422, + TooManyRequests: 429, InternalServerError: 500 } as const @@ -38,6 +39,7 @@ export interface Headers { Authorization?: string 'Content-Length': number 'Content-Type': ContentTypeHeaderValue + 'Set-Cookie'?: string [name: string]: HeaderValue } @@ -48,6 +50,7 @@ export interface HttpRequest< method: Method path: string headers: Headers + cookies?: Record params: Params body: Body } diff --git a/src/library/http/error/TooManyRequestsError.ts b/src/library/http/error/TooManyRequestsError.ts new file mode 100644 index 00000000..7baa05f0 --- /dev/null +++ b/src/library/http/error/TooManyRequestsError.ts @@ -0,0 +1,7 @@ +import { HttpError } from './HttpError' + +export class TooManyRequestsError extends HttpError { + constructor(params: { message: string; report?: unknown; cause?: unknown }) { + super({ ...params, statusCode: 429 }) + } +} diff --git a/src/library/http/parseCookieHeader.ts b/src/library/http/parseCookieHeader.ts new file mode 100644 index 00000000..2ba74077 --- /dev/null +++ b/src/library/http/parseCookieHeader.ts @@ -0,0 +1,28 @@ +export function parseCookieHeader(header: string | undefined): Record { + if (!header) { + return {} + } + + const cookies: Record = {} + for (const part of header.split(';')) { + const trimmed = part.trim() + if (trimmed.length === 0) { + continue + } + + const separator = trimmed.indexOf('=') + if (separator <= 0) { + continue + } + + const name = trimmed.slice(0, separator).trim() + const value = trimmed.slice(separator + 1).trim() + try { + cookies[name] = decodeURIComponent(value) + } catch { + cookies[name] = value + } + } + + return cookies +} diff --git a/test/integration/setup/app-factory.ts b/test/integration/setup/app-factory.ts index 238d7cfb..aa773e63 100644 --- a/test/integration/setup/app-factory.ts +++ b/test/integration/setup/app-factory.ts @@ -23,9 +23,9 @@ export function createTestApp() { knex: knexInstance, logger: new ConsoleLogger(), cors: { - origins: ['*'], - methods: ['GET'], - allowedHeaders: ['Content-Type'] + origins: ['http://localhost:5173'], + methods: ['HEAD', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'] } }) diff --git a/test/integration/setup/schema.sql b/test/integration/setup/schema.sql index 99ab6f08..b7bdabda 100644 --- a/test/integration/setup/schema.sql +++ b/test/integration/setup/schema.sql @@ -53,6 +53,7 @@ COMMENT ON SCHEMA topology IS 'PostGIS Topology schema'; -- CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA public; +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; -- @@ -1394,6 +1395,23 @@ CREATE TABLE public.usuarios ( ); +CREATE TABLE public.usuarios_sessoes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + usuario_id integer NOT NULL, + refresh_token_hash character varying(64) NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + last_used_at timestamp without time zone NOT NULL, + expires_at timestamp without time zone NOT NULL +); + +ALTER TABLE ONLY public.usuarios_sessoes + ADD CONSTRAINT usuarios_sessoes_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX usuarios_sessoes_refresh_token_hash_unique ON public.usuarios_sessoes USING btree (refresh_token_hash); + +CREATE INDEX usuarios_sessoes_usuario_id_index ON public.usuarios_sessoes USING btree (usuario_id); + + -- -- TOC entry 287 (class 1259 OID 31243) -- Name: usuarios_id_seq; Type: SEQUENCE; Schema: public; Owner: - diff --git a/test/integration/usuarioSessao/sessao-http.test.ts b/test/integration/usuarioSessao/sessao-http.test.ts new file mode 100644 index 00000000..7b35e10d --- /dev/null +++ b/test/integration/usuarioSessao/sessao-http.test.ts @@ -0,0 +1,249 @@ +import { + afterAll, describe, expect, test +} from 'vitest' + +import { gerarSenha } from '@/helpers/senhas' + +import { createTestApp } from '../setup/app-factory' + +type Usuario = { + id: number + nome: string + email: string + tipo_usuario_id: number +} + +type SessaoBody = { + access_token: string + refresh_token: string + token_type: string + expires_in: number + user: Usuario + rules: unknown[] +} + +const usuarioColumns = [ + 'id', + 'nome', + 'email', + 'tipo_usuario_id' +] as const + +function cookieHeader(setCookie: string | string[] | undefined): string | undefined { + if (!setCookie) { + return undefined + } + return Array.isArray(setCookie) ? setCookie[0] : setCookie +} + +function refreshFromSetCookie(setCookie: string | string[] | undefined): string | undefined { + const header = cookieHeader(setCookie) + const match = header ? /refresh_token=([^;]+)/.exec(header) : null + return match ? decodeURIComponent(match[1]) : undefined +} + +describe('usuario sessao HTTP', () => { + const { agent, knex } = createTestApp() + const createdUserIds: number[] = [] + + afterAll(async () => { + if (createdUserIds.length > 0) { + const herbarios = await knex('usuarios') + .whereIn('id', createdUserIds) + .pluck('herbario_id') + await knex('usuarios_sessoes').whereIn('usuario_id', createdUserIds).delete() + await knex('usuarios').whereIn('id', createdUserIds).delete() + if (herbarios.length > 0) { + await knex('herbarios').whereIn('id', herbarios).delete() + } + } + await knex.destroy() + }) + + async function insertUsuario(email: string, senha: string) { + let tipo = await knex('tipos_usuarios').orderBy('id', 'asc').first<{ id: number }>() + if (!tipo) { + const inserted = await knex('tipos_usuarios') + .insert({ tipo: 'Curador' }) + .returning<{ id: number }[]>('id') + tipo = inserted[0] + } + if (!tipo) { + throw new Error('tipos_usuarios row is required') + } + + const suffix = email.replace(/[^a-z0-9]/gi, '').slice(0, 20) + const [herbario] = await knex('herbarios') + .insert({ + nome: `Herbario ${suffix}`, + sigla: `H${suffix}`.slice(0, 80) + }) + .returning<{ id: number }[]>('id') + + const usuarios = await knex('usuarios') + .insert({ + nome: 'Usuario Sessao', + email, + senha: gerarSenha(senha), + tipo_usuario_id: tipo.id, + herbario_id: herbario.id + }) + .returning(usuarioColumns) + + const usuario = usuarios[0] + const mapped = { + ...usuario, + id: Number(usuario.id), + tipo_usuario_id: Number(usuario.tipo_usuario_id) + } + createdUserIds.push(mapped.id) + return mapped + } + + test('login, refresh rotation, me, logout, and failed login', async () => { + const senha = 'senha-certa' + const usuario = await insertUsuario('sessao-ok@example.test', senha) + + const login = await agent + .post('/api/auth/login') + .set('X-Forwarded-For', '198.51.100.10') + .send({ email: usuario.email, senha }) + .expect(200) + const loginBody = login.body as SessaoBody + + expect(loginBody).toMatchObject({ + token_type: 'Bearer', + expires_in: 900, + user: { + id: usuario.id, + nome: usuario.nome, + email: usuario.email, + tipo_usuario_id: usuario.tipo_usuario_id + }, + rules: [] + }) + expect(loginBody.access_token).toEqual(expect.any(String)) + expect(loginBody.refresh_token).toEqual(expect.any(String)) + + const firstRefresh = refreshFromSetCookie(login.headers['set-cookie']) + expect(firstRefresh).toBe(loginBody.refresh_token) + expect(cookieHeader(login.headers['set-cookie'])).toContain('HttpOnly') + expect(cookieHeader(login.headers['set-cookie'])).toContain('SameSite=None') + expect(cookieHeader(login.headers['set-cookie'])).toContain('Path=/api/auth') + + const me = await agent + .get('/api/auth/me') + .set('Authorization', `Bearer ${loginBody.access_token}`) + .expect(200) + expect(me.body).toEqual({ + user: { + id: usuario.id, + nome: usuario.nome, + email: usuario.email, + tipo_usuario_id: usuario.tipo_usuario_id + }, + rules: [] + }) + + const refresh = await agent + .post('/api/auth/refresh') + .send({ refresh_token: loginBody.refresh_token }) + .expect(200) + const refreshBody = refresh.body as SessaoBody + expect(refreshBody.refresh_token).not.toBe(loginBody.refresh_token) + expect(refreshBody.access_token).toEqual(expect.any(String)) + + await agent + .post('/api/auth/refresh') + .send({ refresh_token: loginBody.refresh_token }) + .expect(401) + + const cookieRefresh = await agent + .post('/api/auth/refresh') + .set('Cookie', `refresh_token=${refreshBody.refresh_token}`) + .set('X-Requested-With', 'XMLHttpRequest') + .expect(200) + const cookieRefreshBody = cookieRefresh.body as SessaoBody + expect(cookieRefreshBody.refresh_token).toEqual(expect.any(String)) + + await agent + .post('/api/auth/refresh') + .set('Cookie', `refresh_token=${cookieRefreshBody.refresh_token}`) + .set('Origin', 'http://evil.example') + .expect(401) + + await agent + .get('/api/auth/me') + .expect(401) + + const logout = await agent + .post('/api/auth/logout') + .set('Authorization', `Bearer ${cookieRefreshBody.access_token}`) + .expect(204) + expect(cookieHeader(logout.headers['set-cookie'])).toContain('Max-Age=0') + + await agent + .get('/api/auth/me') + .set('Authorization', `Bearer ${cookieRefreshBody.access_token}`) + .expect(401) + + await agent + .post('/api/auth/login') + .set('X-Forwarded-For', '198.51.100.11') + .send({ email: usuario.email, senha: 'errada' }) + .expect(401) + }) + + test('logout all deletes every session for the user', async () => { + const senha = 'senha-certa' + const usuario = await insertUsuario('sessao-all@example.test', senha) + + const first = await agent + .post('/api/auth/login') + .set('X-Forwarded-For', '198.51.100.20') + .send({ email: usuario.email, senha }) + .expect(200) + const firstBody = first.body as SessaoBody + const second = await agent + .post('/api/auth/login') + .set('X-Forwarded-For', '198.51.100.21') + .send({ email: usuario.email, senha }) + .expect(200) + const secondBody = second.body as SessaoBody + + await agent + .post('/api/auth/logout') + .set('Authorization', `Bearer ${firstBody.access_token}`) + .send({ all: true }) + .expect(204) + + await agent + .get('/api/auth/me') + .set('Authorization', `Bearer ${firstBody.access_token}`) + .expect(401) + await agent + .get('/api/auth/me') + .set('Authorization', `Bearer ${secondBody.access_token}`) + .expect(401) + }) + + test('sixth failed login from the same IP is 429', async () => { + const senha = 'senha-certa' + const usuario = await insertUsuario('sessao-limit@example.test', senha) + const ip = '198.51.100.90' + + for (let attempt = 0; attempt < 5; attempt += 1) { + await agent + .post('/api/auth/login') + .set('X-Forwarded-For', ip) + .send({ email: usuario.email, senha: 'errada' }) + .expect(401) + } + + await agent + .post('/api/auth/login') + .set('X-Forwarded-For', ip) + .send({ email: usuario.email, senha: 'errada' }) + .expect(429) + }) +}) diff --git a/test/unit/application/RateLimitMiddleware.test.ts b/test/unit/application/RateLimitMiddleware.test.ts new file mode 100644 index 00000000..2cb0ed55 --- /dev/null +++ b/test/unit/application/RateLimitMiddleware.test.ts @@ -0,0 +1,73 @@ +import { RateLimiterMemory } from 'rate-limiter-flexible' +import { + describe, expect, test, vi +} from 'vitest' + +import { RateLimitMiddleware } from '@/application/RateLimitMiddleware' +import { Method, StatusCode } from '@/library/http/common' +import type { Headers } from '@/library/http/common' +import { HttpError } from '@/library/http/error/HttpError' +import { TooManyRequestsError } from '@/library/http/error/TooManyRequestsError' +import { UnauthorizedError } from '@/library/http/error/UnauthorizedError' + +const headers = {} as Headers + +describe('RateLimitMiddleware', () => { + test('counts only 401 responses and returns 429 after five failures', async () => { + const limiter = new RateLimiterMemory({ + points: 5, + duration: 15 * 60 + }) + const middleware = new RateLimitMiddleware({ + limiter, + isFailure: response => response instanceof HttpError && response.statusCode === 401 + }) + + const request = { + method: Method.Post, + path: '/auth/login', + headers: { 'x-forwarded-for': '203.0.113.10' } as unknown as Headers, + cookies: {}, + params: {}, + body: {} + } + + const unauthorized = new UnauthorizedError({ message: 'Credenciais inválidas' }) + const fail = () => Promise.resolve(unauthorized) + + for (let attempt = 0; attempt < 5; attempt += 1) { + const response = await middleware.handle(request, fail) + expect(response).toBe(unauthorized) + } + + const blocked = await middleware.handle(request, fail) + expect(blocked).toBeInstanceOf(TooManyRequestsError) + expect(blocked instanceof HttpError && blocked.statusCode).toBe(StatusCode.TooManyRequests) + }) + + test('does not consume points on success', async () => { + const limiter = new RateLimiterMemory({ + points: 1, + duration: 15 * 60 + }) + const middleware = new RateLimitMiddleware({ + limiter, + isFailure: response => response instanceof HttpError && response.statusCode === 401 + }) + + const request = { + method: Method.Post, + path: '/auth/login', + headers, + cookies: {}, + params: {}, + body: {} + } + + const ok = { statusCode: StatusCode.Ok, body: { ok: true } } + const succeed = () => Promise.resolve(ok) + await middleware.handle(request, succeed) + const second = await middleware.handle(request, vi.fn(succeed)) + expect(second).toEqual(ok) + }) +}) diff --git a/test/unit/application/parseCorsOrigins.test.ts b/test/unit/application/parseCorsOrigins.test.ts new file mode 100644 index 00000000..64ce0fb3 --- /dev/null +++ b/test/unit/application/parseCorsOrigins.test.ts @@ -0,0 +1,27 @@ +import { + describe, expect, test +} from 'vitest' + +import { assertCookieSafeOrigins, parseCorsOrigins } from '@/application/parseCorsOrigins' + +describe('parseCorsOrigins', () => { + test('splits a comma-separated list', () => { + expect(parseCorsOrigins('http://localhost:5173, https://painel.example')).toEqual([ + 'http://localhost:5173', + 'https://painel.example' + ]) + }) + + test('rejects missing, empty, or wildcard origins', () => { + expect(() => parseCorsOrigins(undefined)).toThrow(/CORS_ORIGINS/) + expect(() => parseCorsOrigins('')).toThrow(/CORS_ORIGINS/) + expect(() => parseCorsOrigins('*')).toThrow(/CORS_ORIGINS/) + expect(() => parseCorsOrigins('http://localhost:5173, *')).toThrow(/CORS_ORIGINS/) + }) +}) + +describe('assertCookieSafeOrigins', () => { + test('rejects *', () => { + expect(() => assertCookieSafeOrigins(['*'])).toThrow(/origins/) + }) +}) diff --git a/test/unit/application/usuarioSessao/refreshCookie.test.ts b/test/unit/application/usuarioSessao/refreshCookie.test.ts new file mode 100644 index 00000000..c690e5c7 --- /dev/null +++ b/test/unit/application/usuarioSessao/refreshCookie.test.ts @@ -0,0 +1,41 @@ +import { + afterEach, describe, expect, test +} from 'vitest' + +import { + REFRESH_COOKIE_NAME, + serializeClearedRefreshCookie, + serializeRefreshCookie +} from '@/application/usuarioSessao/refreshCookie' + +describe('refreshCookie', () => { + afterEach(() => { + process.env.NODE_ENV = 'test' + }) + + test('serializes HttpOnly SameSite=Lax Path=/api/auth without Secure outside production', () => { + process.env.NODE_ENV = 'development' + const cookie = serializeRefreshCookie('secret/value') + + expect(cookie).toContain(`${REFRESH_COOKIE_NAME}=secret%2Fvalue`) + expect(cookie).toContain('HttpOnly') + expect(cookie).toContain('SameSite=Lax') + expect(cookie).toContain('Path=/api/auth') + expect(cookie).not.toContain('Secure') + expect(cookie).not.toContain('SameSite=None') + }) + + test('adds SameSite=None and Secure in production', () => { + process.env.NODE_ENV = 'production' + const cookie = serializeRefreshCookie('token') + expect(cookie).toContain('SameSite=None') + expect(cookie).toContain('Secure') + }) + + test('clears the cookie with Max-Age=0', () => { + const cookie = serializeClearedRefreshCookie() + expect(cookie).toContain(`${REFRESH_COOKIE_NAME}=`) + expect(cookie).toContain('Max-Age=0') + expect(cookie).toContain('Path=/api/auth') + }) +}) diff --git a/test/unit/domain/usuario/FakeUsuarioCollection.ts b/test/unit/domain/usuario/FakeUsuarioCollection.ts new file mode 100644 index 00000000..07c7eb94 --- /dev/null +++ b/test/unit/domain/usuario/FakeUsuarioCollection.ts @@ -0,0 +1,32 @@ +import { + type Attributes, type AttributesComSenha +} from '@/domain/usuario/Usuario' +import { type UsuarioCollection } from '@/domain/usuario/UsuarioCollection' +import { Either } from '@/library/either/Either' + +export class FakeUsuarioCollection implements UsuarioCollection { + readonly byId = new Map() + + add(user: AttributesComSenha): void { + this.byId.set(user.id, { ...user }) + } + + findByEmail(email: string): Promise> { + const row = [...this.byId.values()].find(user => user.email === email) + return Promise.resolve(Either.right(row ? { ...row } : null)) + } + + findById(id: number): Promise> { + const row = this.byId.get(id) + if (!row) { + return Promise.resolve(Either.right(null)) + } + + return Promise.resolve(Either.right({ + id: row.id, + nome: row.nome, + email: row.email, + tipoUsuarioId: row.tipoUsuarioId + })) + } +} diff --git a/test/unit/domain/usuarioSessao/EntraSessaoUseCase.test.ts b/test/unit/domain/usuarioSessao/EntraSessaoUseCase.test.ts new file mode 100644 index 00000000..bc0d7db6 --- /dev/null +++ b/test/unit/domain/usuarioSessao/EntraSessaoUseCase.test.ts @@ -0,0 +1,104 @@ +import { + describe, expect, test +} from 'vitest' + +import { CredenciaisInvalidasError } from '@/domain/usuario/error/CredenciaisInvalidasError' +import { CriaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/CriaUsuarioSessaoUseCase' +import { EntraSessaoUseCase } from '@/domain/usuarioSessao/EntraSessaoUseCase' +import { type AccessToken } from '@/library/auth/AccessToken' +import { AccessTokenInvalidError } from '@/library/auth/error/AccessTokenInvalidError' +import { type RefreshToken } from '@/library/auth/RefreshToken' +import { Either } from '@/library/either/Either' + +import { FakeUsuarioCollection } from '../usuario/FakeUsuarioCollection' +import { FakeUsuarioSessaoCollection } from './FakeUsuarioSessaoCollection' + +const HASH = 'b'.repeat(64) + +function makeRefreshToken(): RefreshToken { + return { + generate: () => Either.right({ + token: 'opaque-refresh', + hash: HASH + }), + hash: token => Either.right(`${token}-hashed`) + } +} + +function makeAccessToken(): AccessToken { + return { + sign: params => Either.right(`access.${params.sub}.${params.sid}`), + verify: () => Either.left(new AccessTokenInvalidError()) + } +} + +describe('EntraSessaoUseCase', () => { + test('creates a session and signs access without a role claim', async () => { + const usuarioCollection = new FakeUsuarioCollection() + usuarioCollection.add({ + id: 7, + nome: 'Ana', + email: 'ana@example.test', + tipoUsuarioId: 2, + senha: 'hash' + }) + + const useCase = new EntraSessaoUseCase({ + usuarioCollection, + criaUsuarioSessaoUseCase: new CriaUsuarioSessaoUseCase({ + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + refreshToken: makeRefreshToken() + }), + accessToken: makeAccessToken(), + comparaSenha: (texto, hash) => texto === 'secret' && hash === 'hash' + }) + + const result = await useCase.execute({ + email: 'ana@example.test', + senha: 'secret' + }) + + expect(result.right()).toBe(true) + if (!result.right()) { + return + } + + expect(result.value.refreshToken).toBe('opaque-refresh') + expect(result.value.user).toEqual({ + id: 7, + nome: 'Ana', + email: 'ana@example.test', + tipoUsuarioId: 2 + }) + expect(result.value.accessToken.startsWith('access.7.')).toBe(true) + }) + + test('rejects unknown email or bad password', async () => { + const usuarioCollection = new FakeUsuarioCollection() + usuarioCollection.add({ + id: 7, + nome: 'Ana', + email: 'ana@example.test', + tipoUsuarioId: 2, + senha: 'hash' + }) + + const useCase = new EntraSessaoUseCase({ + usuarioCollection, + criaUsuarioSessaoUseCase: new CriaUsuarioSessaoUseCase({ + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + refreshToken: makeRefreshToken() + }), + accessToken: makeAccessToken(), + comparaSenha: () => false + }) + + const result = await useCase.execute({ + email: 'ana@example.test', + senha: 'wrong' + }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(CredenciaisInvalidasError) + }) +}) diff --git a/test/unit/infrastructure/UsuarioCollectionKnexAdapter.test.ts b/test/unit/infrastructure/UsuarioCollectionKnexAdapter.test.ts new file mode 100644 index 00000000..f57f9782 --- /dev/null +++ b/test/unit/infrastructure/UsuarioCollectionKnexAdapter.test.ts @@ -0,0 +1,82 @@ +import type { Knex } from 'knex' +import { + describe, expect, test, vi +} from 'vitest' + +import { UsuarioCollectionKnexAdapter } from '@/infrastructure/UsuarioCollectionKnexAdapter' + +const row = { + id: '4', + nome: 'Ana', + email: 'ana@example.test', + senha: 'hash', + tipo_usuario_id: '2' +} + +function stubKnex(promise: Promise): Knex & { builder: Record } { + const builder = {} as Record + builder.select = vi.fn().mockImplementation(() => builder) + builder.where = vi.fn().mockImplementation(() => builder) + builder.first = vi.fn().mockImplementation(() => builder) + builder.then = ( + onResolved: (v: TResult) => unknown, + onRejected?: (e: unknown) => unknown + ): Promise => promise.then(onResolved, onRejected) + + const knex = vi.fn(() => builder) as unknown as Knex & { + builder: Record + } + knex.builder = builder + return knex +} + +describe('UsuarioCollectionKnexAdapter', () => { + test('findByEmail maps snake_case and includes senha', async () => { + const knex = stubKnex(Promise.resolve(row)) + const adapter = new UsuarioCollectionKnexAdapter({ knex }) + + const result = await adapter.findByEmail('ana@example.test') + + expect(result.right()).toBe(true) + expect(result.value).toEqual({ + id: 4, + nome: 'Ana', + email: 'ana@example.test', + tipoUsuarioId: 2, + senha: 'hash' + }) + expect(knex).toHaveBeenCalledWith('usuarios') + expect(knex.builder.where).toHaveBeenCalledWith({ email: 'ana@example.test' }) + }) + + test('findById omits senha', async () => { + const knex = stubKnex(Promise.resolve({ + id: 4, + nome: 'Ana', + email: 'ana@example.test', + tipo_usuario_id: 2 + })) + const adapter = new UsuarioCollectionKnexAdapter({ knex }) + + const result = await adapter.findById(4) + + expect(result.right()).toBe(true) + expect(result.value).toEqual({ + id: 4, + nome: 'Ana', + email: 'ana@example.test', + tipoUsuarioId: 2 + }) + expect(knex.builder.where).toHaveBeenCalledWith({ id: 4 }) + }) + + test('findByEmail returns null when missing', async () => { + const knex = stubKnex(Promise.resolve(undefined)) + const adapter = new UsuarioCollectionKnexAdapter({ knex }) + + const result = await adapter.findByEmail('missing@example.test') + + expect(result.right()).toBe(true) + expect(result.value).toBeNull() + }) +}) diff --git a/test/unit/infrastructure/auth/JwtAccessToken.test.ts b/test/unit/infrastructure/auth/JwtAccessToken.test.ts index 5f4a0652..e9f89e4f 100644 --- a/test/unit/infrastructure/auth/JwtAccessToken.test.ts +++ b/test/unit/infrastructure/auth/JwtAccessToken.test.ts @@ -23,8 +23,7 @@ describe('JwtAccessToken', () => { test('round-trips access claims', () => { const signed = accessToken.sign({ sub: 7, - sid: 'session-1', - role: 2 + sid: 'session-1' }) expect(signed.right()).toBe(true) @@ -38,9 +37,9 @@ describe('JwtAccessToken', () => { expect(result.value).toMatchObject({ sub: 7, sid: 'session-1', - typ: 'access', - role: 2 + typ: 'access' }) + expect(result.value).not.toHaveProperty('role') expect(typeof result.value.iat).toBe('number') expect(typeof result.value.exp).toBe('number') }) @@ -51,8 +50,7 @@ describe('JwtAccessToken', () => { const signed = accessToken.sign({ sub: 1, - sid: 'session-expired', - role: 1 + sid: 'session-expired' }) expect(signed.right()).toBe(true) if (!signed.right()) return @@ -76,8 +74,7 @@ describe('JwtAccessToken', () => { { sub: '1', sid: 'session-2', - typ: 'refresh', - role: 1 + typ: 'refresh' }, SECRET, { expiresIn: '15m' } @@ -93,8 +90,7 @@ describe('JwtAccessToken', () => { { sub: '1', sid: 'session-3', - typ: 'access', - role: 1 + typ: 'access' }, SECRET, { diff --git a/test/unit/library/http/parseCookieHeader.test.ts b/test/unit/library/http/parseCookieHeader.test.ts new file mode 100644 index 00000000..3b82ee96 --- /dev/null +++ b/test/unit/library/http/parseCookieHeader.test.ts @@ -0,0 +1,18 @@ +import { + describe, expect, test +} from 'vitest' + +import { parseCookieHeader } from '@/library/http/parseCookieHeader' + +describe('parseCookieHeader', () => { + test('returns an empty object when the header is missing', () => { + expect(parseCookieHeader(undefined)).toEqual({}) + }) + + test('parses name-value pairs and decodes the value', () => { + expect(parseCookieHeader('refresh_token=a%2Fb; other=1')).toEqual({ + refresh_token: 'a/b', + other: '1' + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 2cfb2b34..2942cf8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4225,6 +4225,11 @@ range-parser@^1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== +rate-limiter-flexible@^11.2.1: + version "11.2.1" + resolved "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-11.2.1.tgz#1d0518f7a118e017eb7dfd2bb818942fbd2b2764" + integrity sha512-JAaz01HZ893zAw+Hx5MdM1z3lLAkyVvNpqR+GNp0k3kjuQB8gY91fXhf5C0osWs+A7iKgFv585qzwmjS3aBHlA== + raw-body@^3.0.0, raw-body@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51" From 31bb74cb46d7811e026e4782ad403ac8d9a20e76 Mon Sep 17 00:00:00 2001 From: Edvaldo Szymonek Date: Sun, 27 Sep 2026 17:05:06 -0300 Subject: [PATCH 2/4] aplica principios SOLID --- package.json | 2 +- ...ller.ts => CriaUsuarioSessaoController.ts} | 16 +- .../usuarioSessao/EncerraSessaoController.ts | 122 ----------- .../EncerraUsuarioSessaoController.ts | 77 +++++++ ...er.ts => MostraUsuarioSessaoController.ts} | 18 +- ...er.ts => RenovaUsuarioSessaoController.ts} | 20 +- src/application/usuarioSessao/index.ts | 77 +++---- src/application/usuarioSessao/sessaoHttp.ts | 4 +- src/domain/usuario/Usuario.ts | 9 + src/domain/usuario/UsuarioCollection.ts | 6 +- ...dasError.ts => InvalidCredentialsError.ts} | 2 +- .../ApagaUsuarioSessaoUseCase.ts | 19 -- .../BuscaUsuarioSessaoPorHashUseCase.ts | 52 ----- .../BuscaUsuarioSessaoPorIdUseCase.ts | 52 ----- .../usuarioSessao/CriaUsuarioSessaoUseCase.ts | 47 +++-- .../EncerraUsuarioSessaoUseCase.ts | 108 ++++++++++ .../usuarioSessao/EntraSessaoUseCase.ts | 62 ------ .../usuarioSessao/MostraSessaoUseCase.ts | 50 ----- .../MostraUsuarioSessaoUseCase.ts | 75 +++++++ .../usuarioSessao/RenovaSessaoUseCase.ts | 61 ------ ...eCase.ts => RenovaUsuarioSessaoUseCase.ts} | 43 +++- src/domain/usuarioSessao/UsuarioSessao.ts | 11 + src/factory/RateLimiterFactory.ts | 9 +- .../UsuarioCollectionKnexAdapter.ts | 63 ++---- ...sao-http.test.ts => usuarioSessao.test.ts} | 0 .../domain/usuario/FakeUsuarioCollection.ts | 19 +- .../ApagaUsuarioSessaoUseCase.test.ts | 33 --- .../BuscaUsuarioSessaoPorHashUseCase.test.ts | 64 ------ .../BuscaUsuarioSessaoPorIdUseCase.test.ts | 57 ----- .../CriaUsuarioSessaoUseCase.test.ts | 104 ++++++++-- .../EncerraUsuarioSessaoUseCase.test.ts | 194 ++++++++++++++++++ .../usuarioSessao/EntraSessaoUseCase.test.ts | 104 ---------- .../MostraUsuarioSessaoUseCase.test.ts | 124 +++++++++++ .../RenovaUsuarioSessaoUseCase.test.ts | 157 ++++++++++++++ .../RotacionaUsuarioSessaoUseCase.test.ts | 100 --------- .../UsuarioCollectionKnexAdapter.test.ts | 8 +- yarn.lock | 2 +- 37 files changed, 1017 insertions(+), 954 deletions(-) rename src/application/usuarioSessao/{EntraSessaoController.ts => CriaUsuarioSessaoController.ts} (65%) delete mode 100644 src/application/usuarioSessao/EncerraSessaoController.ts create mode 100644 src/application/usuarioSessao/EncerraUsuarioSessaoController.ts rename src/application/usuarioSessao/{MostraSessaoController.ts => MostraUsuarioSessaoController.ts} (61%) rename src/application/usuarioSessao/{RenovaSessaoController.ts => RenovaUsuarioSessaoController.ts} (65%) rename src/domain/usuario/error/{CredenciaisInvalidasError.ts => InvalidCredentialsError.ts} (76%) delete mode 100644 src/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.ts delete mode 100644 src/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.ts delete mode 100644 src/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.ts create mode 100644 src/domain/usuarioSessao/EncerraUsuarioSessaoUseCase.ts delete mode 100644 src/domain/usuarioSessao/EntraSessaoUseCase.ts delete mode 100644 src/domain/usuarioSessao/MostraSessaoUseCase.ts create mode 100644 src/domain/usuarioSessao/MostraUsuarioSessaoUseCase.ts delete mode 100644 src/domain/usuarioSessao/RenovaSessaoUseCase.ts rename src/domain/usuarioSessao/{RotacionaUsuarioSessaoUseCase.ts => RenovaUsuarioSessaoUseCase.ts} (61%) rename test/integration/usuarioSessao/{sessao-http.test.ts => usuarioSessao.test.ts} (100%) delete mode 100644 test/unit/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.test.ts delete mode 100644 test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.test.ts delete mode 100644 test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.test.ts create mode 100644 test/unit/domain/usuarioSessao/EncerraUsuarioSessaoUseCase.test.ts delete mode 100644 test/unit/domain/usuarioSessao/EntraSessaoUseCase.test.ts create mode 100644 test/unit/domain/usuarioSessao/MostraUsuarioSessaoUseCase.test.ts create mode 100644 test/unit/domain/usuarioSessao/RenovaUsuarioSessaoUseCase.test.ts delete mode 100644 test/unit/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.test.ts diff --git a/package.json b/package.json index a9f13b38..d893439c 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "pg": "^8.16.3", "puppeteer": "24.28.0", "q": "1.5.1", - "rate-limiter-flexible": "^11.2.1", + "rate-limiter-flexible": "11.2.1", "react": "19.2.0", "react-dom": "19.2.0", "request": "2.88.2", diff --git a/src/application/usuarioSessao/EntraSessaoController.ts b/src/application/usuarioSessao/CriaUsuarioSessaoController.ts similarity index 65% rename from src/application/usuarioSessao/EntraSessaoController.ts rename to src/application/usuarioSessao/CriaUsuarioSessaoController.ts index 3cf26069..8a57fe6c 100644 --- a/src/application/usuarioSessao/EntraSessaoController.ts +++ b/src/application/usuarioSessao/CriaUsuarioSessaoController.ts @@ -1,5 +1,5 @@ -import { CredenciaisInvalidasError } from '@/domain/usuario/error/CredenciaisInvalidasError' -import { type EntraSessaoUseCase } from '@/domain/usuarioSessao/EntraSessaoUseCase' +import { InvalidCredentialsError } from '@/domain/usuario/error/InvalidCredentialsError' +import { type CriaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/CriaUsuarioSessaoUseCase' import { HttpRequest, HttpResponse, StatusCode } from '@/library/http/common' @@ -11,14 +11,14 @@ import { serializeRefreshCookie } from './refreshCookie' import { credenciaisInvalidas, sessaoResponseBody } from './sessaoHttp' interface Dependencies { - entraSessaoUseCase: EntraSessaoUseCase + criaUsuarioSessaoUseCase: CriaUsuarioSessaoUseCase } -export class EntraSessaoController implements RequestHandler { - private readonly entraSessaoUseCase: EntraSessaoUseCase +export class CriaUsuarioSessaoController implements RequestHandler { + private readonly criaUsuarioSessaoUseCase: CriaUsuarioSessaoUseCase constructor(dependencies: Dependencies) { - this.entraSessaoUseCase = dependencies.entraSessaoUseCase + this.criaUsuarioSessaoUseCase = dependencies.criaUsuarioSessaoUseCase } async handle(request: HttpRequest, _next: NextHandler): Promise { @@ -26,9 +26,9 @@ export class EntraSessaoController implements RequestHandler { const email = typeof body?.email === 'string' ? body.email : '' const senha = typeof body?.senha === 'string' ? body.senha : '' - const result = await this.entraSessaoUseCase.execute({ email, senha }) + const result = await this.criaUsuarioSessaoUseCase.execute({ email, senha }) if (result.left()) { - if (result.value instanceof CredenciaisInvalidasError) { + if (result.value instanceof InvalidCredentialsError) { return credenciaisInvalidas() } return new InternalServerError({ message: result.value.message }) diff --git a/src/application/usuarioSessao/EncerraSessaoController.ts b/src/application/usuarioSessao/EncerraSessaoController.ts deleted file mode 100644 index 37f19220..00000000 --- a/src/application/usuarioSessao/EncerraSessaoController.ts +++ /dev/null @@ -1,122 +0,0 @@ -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, - naoAutorizado, - 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 { - 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 naoAutorizado() - } - - if (logoutAllRequested(request.body)) { - if (!verifiedAccess || verifiedAccess.left()) { - return this.cleared(naoAutorizado()) - } - - 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(naoAutorizado()) - } - - 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(naoAutorizado()) - } - - 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() - } - } - } -} diff --git a/src/application/usuarioSessao/EncerraUsuarioSessaoController.ts b/src/application/usuarioSessao/EncerraUsuarioSessaoController.ts new file mode 100644 index 00000000..0dd9b85b --- /dev/null +++ b/src/application/usuarioSessao/EncerraUsuarioSessaoController.ts @@ -0,0 +1,77 @@ +import { type EncerraUsuarioSessaoUseCase } from '@/domain/usuarioSessao/EncerraUsuarioSessaoUseCase' +import { UserSessionNotFoundError } from '@/domain/usuarioSessao/error/UserSessionNotFoundError' +import { type AccessToken } from '@/library/auth/AccessToken' +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 { + accessToken: AccessToken + encerraUsuarioSessaoUseCase: EncerraUsuarioSessaoUseCase +} + +export class EncerraUsuarioSessaoController implements RequestHandler { + private readonly accessToken: AccessToken + private readonly encerraUsuarioSessaoUseCase: EncerraUsuarioSessaoUseCase + + constructor(dependencies: Dependencies) { + this.accessToken = dependencies.accessToken + this.encerraUsuarioSessaoUseCase = dependencies.encerraUsuarioSessaoUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + 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() + } + + const result = await this.encerraUsuarioSessaoUseCase.execute({ + refreshToken: refresh?.token, + accessToken: access, + all: logoutAllRequested(request.body) + }) + if (result.left()) { + if (result.value instanceof UserSessionNotFoundError) { + return this.cleared(notAuthorized()) + } + return new InternalServerError({ message: result.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() + } + } + } +} diff --git a/src/application/usuarioSessao/MostraSessaoController.ts b/src/application/usuarioSessao/MostraUsuarioSessaoController.ts similarity index 61% rename from src/application/usuarioSessao/MostraSessaoController.ts rename to src/application/usuarioSessao/MostraUsuarioSessaoController.ts index 3f30c12b..bc515926 100644 --- a/src/application/usuarioSessao/MostraSessaoController.ts +++ b/src/application/usuarioSessao/MostraUsuarioSessaoController.ts @@ -1,5 +1,5 @@ import { UserSessionNotFoundError } from '@/domain/usuarioSessao/error/UserSessionNotFoundError' -import { type MostraSessaoUseCase } from '@/domain/usuarioSessao/MostraSessaoUseCase' +import { type MostraUsuarioSessaoUseCase } from '@/domain/usuarioSessao/MostraUsuarioSessaoUseCase' import { HttpRequest, HttpResponse, StatusCode } from '@/library/http/common' @@ -8,30 +8,30 @@ import { InternalServerError } from '@/library/http/error/InternalServerError' import { type NextHandler, type RequestHandler } from '@/library/http/Server' import { - meResponseBody, naoAutorizado, readBearerAccess + meResponseBody, notAuthorized, readBearerAccess } from './sessaoHttp' interface Dependencies { - mostraSessaoUseCase: MostraSessaoUseCase + mostraUsuarioSessaoUseCase: MostraUsuarioSessaoUseCase } -export class MostraSessaoController implements RequestHandler { - private readonly mostraSessaoUseCase: MostraSessaoUseCase +export class MostraUsuarioSessaoController implements RequestHandler { + private readonly mostraUsuarioSessaoUseCase: MostraUsuarioSessaoUseCase constructor(dependencies: Dependencies) { - this.mostraSessaoUseCase = dependencies.mostraSessaoUseCase + this.mostraUsuarioSessaoUseCase = dependencies.mostraUsuarioSessaoUseCase } async handle(request: HttpRequest, _next: NextHandler): Promise { const token = readBearerAccess(request) if (!token) { - return naoAutorizado() + return notAuthorized() } - const result = await this.mostraSessaoUseCase.execute({ accessToken: token }) + const result = await this.mostraUsuarioSessaoUseCase.execute({ accessToken: token }) if (result.left()) { if (result.value instanceof UserSessionNotFoundError) { - return naoAutorizado() + return notAuthorized() } return new InternalServerError({ message: result.value.message }) } diff --git a/src/application/usuarioSessao/RenovaSessaoController.ts b/src/application/usuarioSessao/RenovaUsuarioSessaoController.ts similarity index 65% rename from src/application/usuarioSessao/RenovaSessaoController.ts rename to src/application/usuarioSessao/RenovaUsuarioSessaoController.ts index 9f90098b..8d45a5a8 100644 --- a/src/application/usuarioSessao/RenovaSessaoController.ts +++ b/src/application/usuarioSessao/RenovaUsuarioSessaoController.ts @@ -1,5 +1,5 @@ import { UserSessionNotFoundError } from '@/domain/usuarioSessao/error/UserSessionNotFoundError' -import { type RenovaSessaoUseCase } from '@/domain/usuarioSessao/RenovaSessaoUseCase' +import { type RenovaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/RenovaUsuarioSessaoUseCase' import { HttpRequest, HttpResponse, StatusCode } from '@/library/http/common' @@ -9,33 +9,33 @@ import { type NextHandler, type RequestHandler } from '@/library/http/Server' import { serializeRefreshCookie } from './refreshCookie' import { - hasCsrfHeader, looksLikeBrowserRequest, naoAutorizado, resolveRefreshToken, sessaoResponseBody + hasCsrfHeader, looksLikeBrowserRequest, notAuthorized, resolveRefreshToken, sessaoResponseBody } from './sessaoHttp' interface Dependencies { - renovaSessaoUseCase: RenovaSessaoUseCase + renovaUsuarioSessaoUseCase: RenovaUsuarioSessaoUseCase } -export class RenovaSessaoController implements RequestHandler { - private readonly renovaSessaoUseCase: RenovaSessaoUseCase +export class RenovaUsuarioSessaoController implements RequestHandler { + private readonly renovaUsuarioSessaoUseCase: RenovaUsuarioSessaoUseCase constructor(dependencies: Dependencies) { - this.renovaSessaoUseCase = dependencies.renovaSessaoUseCase + this.renovaUsuarioSessaoUseCase = dependencies.renovaUsuarioSessaoUseCase } async handle(request: HttpRequest, _next: NextHandler): Promise { const refresh = resolveRefreshToken(request) if (!refresh) { - return naoAutorizado() + return notAuthorized() } if (refresh.cookieOnly && looksLikeBrowserRequest(request) && !hasCsrfHeader(request)) { - return naoAutorizado() + return notAuthorized() } - const result = await this.renovaSessaoUseCase.execute({ refreshToken: refresh.token }) + const result = await this.renovaUsuarioSessaoUseCase.execute({ refreshToken: refresh.token }) if (result.left()) { if (result.value instanceof UserSessionNotFoundError) { - return naoAutorizado() + return notAuthorized() } return new InternalServerError({ message: result.value.message }) } diff --git a/src/application/usuarioSessao/index.ts b/src/application/usuarioSessao/index.ts index 8d080df0..7011d4c1 100644 --- a/src/application/usuarioSessao/index.ts +++ b/src/application/usuarioSessao/index.ts @@ -1,29 +1,22 @@ import { type Knex } from 'knex' -import { RateLimitMiddleware } from '@/application/RateLimitMiddleware' -import { ApagaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/ApagaUsuarioSessaoUseCase' -import { ApagaUsuarioSessoesUseCase } from '@/domain/usuarioSessao/ApagaUsuarioSessoesUseCase' -import { BuscaUsuarioSessaoPorHashUseCase } from '@/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase' -import { BuscaUsuarioSessaoPorIdUseCase } from '@/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase' import { CriaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/CriaUsuarioSessaoUseCase' -import { EntraSessaoUseCase } from '@/domain/usuarioSessao/EntraSessaoUseCase' -import { MostraSessaoUseCase } from '@/domain/usuarioSessao/MostraSessaoUseCase' -import { RenovaSessaoUseCase } from '@/domain/usuarioSessao/RenovaSessaoUseCase' -import { RotacionaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase' +import { EncerraUsuarioSessaoUseCase } from '@/domain/usuarioSessao/EncerraUsuarioSessaoUseCase' +import { MostraUsuarioSessaoUseCase } from '@/domain/usuarioSessao/MostraUsuarioSessaoUseCase' +import { RenovaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/RenovaUsuarioSessaoUseCase' import { createAccessToken } from '@/factory/AccessTokenFactory' -import { createRateLimiter } from '@/factory/RateLimiterFactory' +import { rateLimitMiddleware } from '@/factory/RateLimiterFactory' import { createRefreshToken } from '@/factory/RefreshTokenFactory' import { comparaSenha } from '@/helpers/senhas' import { UsuarioCollectionKnexAdapter } from '@/infrastructure/UsuarioCollectionKnexAdapter' import { UsuarioSessaoCollectionKnexAdapter } from '@/infrastructure/UsuarioSessaoCollectionKnexAdapter' import { Method } from '@/library/http/common' -import { HttpError } from '@/library/http/error/HttpError' import { Route } from '@/library/http/Router' -import { EncerraSessaoController } from './EncerraSessaoController' -import { EntraSessaoController } from './EntraSessaoController' -import { MostraSessaoController } from './MostraSessaoController' -import { RenovaSessaoController } from './RenovaSessaoController' +import { CriaUsuarioSessaoController } from './CriaUsuarioSessaoController' +import { EncerraUsuarioSessaoController } from './EncerraUsuarioSessaoController' +import { MostraUsuarioSessaoController } from './MostraUsuarioSessaoController' +import { RenovaUsuarioSessaoController } from './RenovaUsuarioSessaoController' export function routes(knex: Knex): Route[] { const usuarioCollection = new UsuarioCollectionKnexAdapter({ knex }) @@ -31,36 +24,17 @@ export function routes(knex: Knex): Route[] { const refreshToken = createRefreshToken() const accessToken = createAccessToken() - const criaUsuarioSessaoUseCase = new CriaUsuarioSessaoUseCase({ - usuarioSessaoCollection, - refreshToken - }) - const rotacionaUsuarioSessaoUseCase = new RotacionaUsuarioSessaoUseCase({ - usuarioSessaoCollection, - refreshToken - }) - const apagaUsuarioSessaoUseCase = new ApagaUsuarioSessaoUseCase({ usuarioSessaoCollection }) - const apagaUsuarioSessoesUseCase = new ApagaUsuarioSessoesUseCase({ usuarioSessaoCollection }) - const buscaUsuarioSessaoPorIdUseCase = new BuscaUsuarioSessaoPorIdUseCase({ - usuarioSessaoCollection - }) - const buscaUsuarioSessaoPorHashUseCase = new BuscaUsuarioSessaoPorHashUseCase({ - usuarioSessaoCollection - }) - return [ { method: Method.Post, path: '/auth/login', handlers: [ - new RateLimitMiddleware({ - limiter: createRateLimiter(), - isFailure: response => response instanceof HttpError && response.statusCode === 401 - }), - new EntraSessaoController({ - entraSessaoUseCase: new EntraSessaoUseCase({ + rateLimitMiddleware, + new CriaUsuarioSessaoController({ + criaUsuarioSessaoUseCase: new CriaUsuarioSessaoUseCase({ usuarioCollection, - criaUsuarioSessaoUseCase, + usuarioSessaoCollection, + refreshToken, accessToken, comparaSenha }) @@ -71,11 +45,11 @@ export function routes(knex: Knex): Route[] { method: Method.Post, path: '/auth/refresh', handlers: [ - new RenovaSessaoController({ - renovaSessaoUseCase: new RenovaSessaoUseCase({ + new RenovaUsuarioSessaoController({ + renovaUsuarioSessaoUseCase: new RenovaUsuarioSessaoUseCase({ usuarioCollection, - rotacionaUsuarioSessaoUseCase, - apagaUsuarioSessaoUseCase, + usuarioSessaoCollection, + refreshToken, accessToken }) }) @@ -85,12 +59,13 @@ export function routes(knex: Knex): Route[] { method: Method.Post, path: '/auth/logout', handlers: [ - new EncerraSessaoController({ - refreshToken, + new EncerraUsuarioSessaoController({ accessToken, - buscaUsuarioSessaoPorHashUseCase, - apagaUsuarioSessaoUseCase, - apagaUsuarioSessoesUseCase + encerraUsuarioSessaoUseCase: new EncerraUsuarioSessaoUseCase({ + usuarioSessaoCollection, + refreshToken, + accessToken + }) }) ] }, @@ -98,11 +73,11 @@ export function routes(knex: Knex): Route[] { method: Method.Get, path: '/auth/me', handlers: [ - new MostraSessaoController({ - mostraSessaoUseCase: new MostraSessaoUseCase({ + new MostraUsuarioSessaoController({ + mostraUsuarioSessaoUseCase: new MostraUsuarioSessaoUseCase({ accessToken, usuarioCollection, - buscaUsuarioSessaoPorIdUseCase + usuarioSessaoCollection }) }) ] diff --git a/src/application/usuarioSessao/sessaoHttp.ts b/src/application/usuarioSessao/sessaoHttp.ts index aa106e40..039626b6 100644 --- a/src/application/usuarioSessao/sessaoHttp.ts +++ b/src/application/usuarioSessao/sessaoHttp.ts @@ -18,8 +18,8 @@ export function credenciaisInvalidas(): UnauthorizedError { return new UnauthorizedError({ message: 'Credenciais inválidas' }) } -export function naoAutorizado(): UnauthorizedError { - return new UnauthorizedError({ message: 'Não autorizado' }) +export function notAuthorized(): UnauthorizedError { + return new UnauthorizedError({ message: 'Unauthorized' }) } export function toSessaoUsuario(user: Attributes): SessaoUsuario { diff --git a/src/domain/usuario/Usuario.ts b/src/domain/usuario/Usuario.ts index 0dd10cbc..0c55f6f3 100644 --- a/src/domain/usuario/Usuario.ts +++ b/src/domain/usuario/Usuario.ts @@ -41,4 +41,13 @@ export class Usuario { return Either.right(new Usuario(attributes)) } + + toAttributes(): Attributes { + return { + id: this.id, + nome: this.nome, + email: this.email, + tipoUsuarioId: this.tipoUsuarioId + } + } } diff --git a/src/domain/usuario/UsuarioCollection.ts b/src/domain/usuario/UsuarioCollection.ts index 9dc66ef6..a3f741db 100644 --- a/src/domain/usuario/UsuarioCollection.ts +++ b/src/domain/usuario/UsuarioCollection.ts @@ -1,8 +1,8 @@ import { type Either } from '@/library/either/Either' -import { type Attributes, type AttributesComSenha } from './Usuario' +import { type Usuario } from './Usuario' export interface UsuarioCollection { - findByEmail(email: string): Promise> - findById(id: number): Promise> + findByEmail(email: string): Promise> + findById(id: number): Promise> } diff --git a/src/domain/usuario/error/CredenciaisInvalidasError.ts b/src/domain/usuario/error/InvalidCredentialsError.ts similarity index 76% rename from src/domain/usuario/error/CredenciaisInvalidasError.ts rename to src/domain/usuario/error/InvalidCredentialsError.ts index b6095979..b550bf6a 100644 --- a/src/domain/usuario/error/CredenciaisInvalidasError.ts +++ b/src/domain/usuario/error/InvalidCredentialsError.ts @@ -1,6 +1,6 @@ import { BaseError } from '@/library/BaseError' -export class CredenciaisInvalidasError extends BaseError { +export class InvalidCredentialsError extends BaseError { constructor(params?: { cause?: unknown }) { super({ message: 'Credenciais inválidas', diff --git a/src/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.ts b/src/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.ts deleted file mode 100644 index e4876fd3..00000000 --- a/src/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { type Either } from '@/library/either/Either' - -import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' - -interface Dependencies { - usuarioSessaoCollection: UsuarioSessaoCollection -} - -export class ApagaUsuarioSessaoUseCase { - private readonly usuarioSessaoCollection: UsuarioSessaoCollection - - constructor(dependencies: Dependencies) { - this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection - } - - async execute(params: { id: string }): Promise> { - return this.usuarioSessaoCollection.deleteById(params.id) - } -} diff --git a/src/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.ts b/src/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.ts deleted file mode 100644 index 3bf76467..00000000 --- a/src/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Either } from '@/library/either/Either' - -import { type Attributes, UsuarioSessao } from './UsuarioSessao' -import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' - -interface Dependencies { - usuarioSessaoCollection: UsuarioSessaoCollection - now?: () => Date -} - -export class BuscaUsuarioSessaoPorHashUseCase { - private readonly usuarioSessaoCollection: UsuarioSessaoCollection - private readonly now: () => Date - - constructor(dependencies: Dependencies) { - this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection - this.now = dependencies.now ?? (() => new Date()) - } - - async execute(params: { refreshTokenHash: string }): Promise> { - const found = await this.usuarioSessaoCollection.findByRefreshTokenHash(params.refreshTokenHash) - if (found.left()) { - return found - } - if (!found.value) { - return Either.right(null) - } - - const expired = await this.deleteIfExpired(found.value) - if (expired.left()) { - return expired - } - if (expired.value) { - return Either.right(null) - } - - return Either.right(found.value) - } - - private async deleteIfExpired(session: Attributes): Promise> { - if (!UsuarioSessao.expired(session.expiresAt, this.now())) { - return Either.right(false) - } - - const deleted = await this.usuarioSessaoCollection.deleteById(session.id) - if (deleted.left()) { - return deleted - } - - return Either.right(true) - } -} diff --git a/src/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.ts b/src/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.ts deleted file mode 100644 index f64a30b2..00000000 --- a/src/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Either } from '@/library/either/Either' - -import { type Attributes, UsuarioSessao } from './UsuarioSessao' -import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' - -interface Dependencies { - usuarioSessaoCollection: UsuarioSessaoCollection - now?: () => Date -} - -export class BuscaUsuarioSessaoPorIdUseCase { - private readonly usuarioSessaoCollection: UsuarioSessaoCollection - private readonly now: () => Date - - constructor(dependencies: Dependencies) { - this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection - this.now = dependencies.now ?? (() => new Date()) - } - - async execute(params: { id: string }): Promise> { - const found = await this.usuarioSessaoCollection.findById(params.id) - if (found.left()) { - return found - } - if (!found.value) { - return Either.right(null) - } - - const expired = await this.deleteIfExpired(found.value) - if (expired.left()) { - return expired - } - if (expired.value) { - return Either.right(null) - } - - return Either.right(found.value) - } - - private async deleteIfExpired(session: Attributes): Promise> { - if (!UsuarioSessao.expired(session.expiresAt, this.now())) { - return Either.right(false) - } - - const deleted = await this.usuarioSessaoCollection.deleteById(session.id) - if (deleted.left()) { - return deleted - } - - return Either.right(true) - } -} diff --git a/src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts b/src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts index 11b59d47..a2915419 100644 --- a/src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts +++ b/src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts @@ -1,29 +1,50 @@ import { randomUUID } from 'node:crypto' +import { InvalidCredentialsError } from '@/domain/usuario/error/InvalidCredentialsError' +import { type UsuarioCollection } from '@/domain/usuario/UsuarioCollection' +import { type AccessToken } from '@/library/auth/AccessToken' import { type RefreshToken } from '@/library/auth/RefreshToken' import { Either } from '@/library/either/Either' +import { type SessaoAutenticada } from './sessaoAutenticada' import { type Attributes, UsuarioSessao } from './UsuarioSessao' import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' interface Dependencies { + usuarioCollection: UsuarioCollection usuarioSessaoCollection: UsuarioSessaoCollection refreshToken: RefreshToken + accessToken: AccessToken + comparaSenha: (texto: string, hash: string) => boolean now?: () => Date } export class CriaUsuarioSessaoUseCase { + private readonly usuarioCollection: UsuarioCollection private readonly usuarioSessaoCollection: UsuarioSessaoCollection private readonly refreshToken: RefreshToken + private readonly accessToken: AccessToken + private readonly comparaSenha: Dependencies['comparaSenha'] private readonly now: () => Date constructor(dependencies: Dependencies) { + this.usuarioCollection = dependencies.usuarioCollection this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection this.refreshToken = dependencies.refreshToken + this.accessToken = dependencies.accessToken + this.comparaSenha = dependencies.comparaSenha this.now = dependencies.now ?? (() => new Date()) } - async execute(params: { usuarioId: number }): Promise> { + async execute(params: { email: string; senha: string }): Promise> { + const found = await this.usuarioCollection.findByEmail(params.email) + if (found.left()) { + return found + } + if (!found.value?.senha || !this.comparaSenha(params.senha, found.value.senha)) { + return Either.left(new InvalidCredentialsError()) + } + const generated = this.refreshToken.generate() if (generated.left()) { return generated @@ -32,7 +53,7 @@ export class CriaUsuarioSessaoUseCase { const createdAt = this.now() const attributes: Attributes = { id: randomUUID(), - usuarioId: params.usuarioId, + usuarioId: found.value.id, refreshTokenHash: generated.value.hash, createdAt, lastUsedAt: createdAt, @@ -44,21 +65,23 @@ export class CriaUsuarioSessaoUseCase { return session } - const persisted = await this.usuarioSessaoCollection.create({ - id: session.value.id, - usuarioId: session.value.usuarioId, - refreshTokenHash: session.value.refreshTokenHash, - createdAt: session.value.createdAt, - lastUsedAt: session.value.lastUsedAt, - expiresAt: session.value.expiresAt - }) + const persisted = await this.usuarioSessaoCollection.create(session.value.toAttributes()) if (persisted.left()) { return persisted } + const signed = this.accessToken.sign({ + sub: found.value.id, + sid: persisted.value.id + }) + if (signed.left()) { + return signed + } + return Either.right({ - session: persisted.value, - refreshToken: generated.value.token + accessToken: signed.value, + refreshToken: generated.value.token, + user: found.value.toAttributes() }) } } diff --git a/src/domain/usuarioSessao/EncerraUsuarioSessaoUseCase.ts b/src/domain/usuarioSessao/EncerraUsuarioSessaoUseCase.ts new file mode 100644 index 00000000..e98f3ba3 --- /dev/null +++ b/src/domain/usuarioSessao/EncerraUsuarioSessaoUseCase.ts @@ -0,0 +1,108 @@ +import { type AccessToken } from '@/library/auth/AccessToken' +import { type RefreshToken } from '@/library/auth/RefreshToken' +import { Either } from '@/library/either/Either' + +import { UserSessionNotFoundError } from './error/UserSessionNotFoundError' +import { type Attributes, UsuarioSessao } from './UsuarioSessao' +import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' + +interface Dependencies { + usuarioSessaoCollection: UsuarioSessaoCollection + refreshToken: RefreshToken + accessToken: AccessToken + now?: () => Date +} + +export class EncerraUsuarioSessaoUseCase { + private readonly usuarioSessaoCollection: UsuarioSessaoCollection + private readonly refreshToken: RefreshToken + private readonly accessToken: AccessToken + private readonly now: () => Date + + constructor(dependencies: Dependencies) { + this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection + this.refreshToken = dependencies.refreshToken + this.accessToken = dependencies.accessToken + this.now = dependencies.now ?? (() => new Date()) + } + + async execute(params: { + refreshToken?: string + accessToken?: string + all?: boolean + }): Promise> { + if (params.all) { + return this.deleteAll(params.accessToken) + } + + if (params.refreshToken) { + return this.deleteByRefreshToken(params.refreshToken) + } + + if (params.accessToken) { + return this.deleteByAccessToken(params.accessToken) + } + + return Either.left(new UserSessionNotFoundError()) + } + + private async deleteAll(accessToken: string | undefined): Promise> { + if (!accessToken) { + return Either.left(new UserSessionNotFoundError()) + } + + const verified = this.accessToken.verify(accessToken) + if (verified.left()) { + return Either.left(new UserSessionNotFoundError({ cause: verified.value })) + } + + return this.usuarioSessaoCollection.deleteByUsuarioId(verified.value.sub) + } + + private async deleteByRefreshToken(refreshToken: string): Promise> { + const hashed = this.refreshToken.hash(refreshToken) + if (hashed.left()) { + return Either.left(new UserSessionNotFoundError({ cause: hashed.value })) + } + + const found = await this.usuarioSessaoCollection.findByRefreshTokenHash(hashed.value) + if (found.left()) { + return found + } + if (!found.value) { + return Either.right(undefined) + } + + const expired = await this.deleteIfExpired(found.value) + if (expired.left()) { + return expired + } + if (expired.value) { + return Either.right(undefined) + } + + return this.usuarioSessaoCollection.deleteById(found.value.id) + } + + private async deleteByAccessToken(accessToken: string): Promise> { + const verified = this.accessToken.verify(accessToken) + if (verified.left()) { + return Either.left(new UserSessionNotFoundError({ cause: verified.value })) + } + + return this.usuarioSessaoCollection.deleteById(verified.value.sid) + } + + private async deleteIfExpired(session: Attributes): Promise> { + if (!UsuarioSessao.expired(session.expiresAt, this.now())) { + return Either.right(false) + } + + const deleted = await this.usuarioSessaoCollection.deleteById(session.id) + if (deleted.left()) { + return deleted + } + + return Either.right(true) + } +} diff --git a/src/domain/usuarioSessao/EntraSessaoUseCase.ts b/src/domain/usuarioSessao/EntraSessaoUseCase.ts deleted file mode 100644 index 71eff641..00000000 --- a/src/domain/usuarioSessao/EntraSessaoUseCase.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { CredenciaisInvalidasError } from '@/domain/usuario/error/CredenciaisInvalidasError' -import { type UsuarioCollection } from '@/domain/usuario/UsuarioCollection' -import { type AccessToken } from '@/library/auth/AccessToken' -import { Either } from '@/library/either/Either' - -import { type CriaUsuarioSessaoUseCase } from './CriaUsuarioSessaoUseCase' -import { type SessaoAutenticada } from './sessaoAutenticada' - -interface Dependencies { - usuarioCollection: UsuarioCollection - criaUsuarioSessaoUseCase: CriaUsuarioSessaoUseCase - accessToken: AccessToken - comparaSenha: (texto: string, hash: string) => boolean -} - -export class EntraSessaoUseCase { - private readonly usuarioCollection: UsuarioCollection - private readonly criaUsuarioSessaoUseCase: CriaUsuarioSessaoUseCase - private readonly accessToken: AccessToken - private readonly comparaSenha: Dependencies['comparaSenha'] - - constructor(dependencies: Dependencies) { - this.usuarioCollection = dependencies.usuarioCollection - this.criaUsuarioSessaoUseCase = dependencies.criaUsuarioSessaoUseCase - this.accessToken = dependencies.accessToken - this.comparaSenha = dependencies.comparaSenha - } - - async execute(params: { email: string; senha: string }): Promise> { - const found = await this.usuarioCollection.findByEmail(params.email) - if (found.left()) { - return found - } - if (!found.value || !this.comparaSenha(params.senha, found.value.senha)) { - return Either.left(new CredenciaisInvalidasError()) - } - - const created = await this.criaUsuarioSessaoUseCase.execute({ usuarioId: found.value.id }) - if (created.left()) { - return created - } - - const signed = this.accessToken.sign({ - sub: found.value.id, - sid: created.value.session.id - }) - if (signed.left()) { - return signed - } - - return Either.right({ - accessToken: signed.value, - refreshToken: created.value.refreshToken, - user: { - id: found.value.id, - nome: found.value.nome, - email: found.value.email, - tipoUsuarioId: found.value.tipoUsuarioId - } - }) - } -} diff --git a/src/domain/usuarioSessao/MostraSessaoUseCase.ts b/src/domain/usuarioSessao/MostraSessaoUseCase.ts deleted file mode 100644 index dcb264d7..00000000 --- a/src/domain/usuarioSessao/MostraSessaoUseCase.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { type Attributes } from '@/domain/usuario/Usuario' -import { type UsuarioCollection } from '@/domain/usuario/UsuarioCollection' -import { type AccessToken } from '@/library/auth/AccessToken' -import { Either } from '@/library/either/Either' - -import { type BuscaUsuarioSessaoPorIdUseCase } from './BuscaUsuarioSessaoPorIdUseCase' -import { UserSessionNotFoundError } from './error/UserSessionNotFoundError' - -interface Dependencies { - accessToken: AccessToken - usuarioCollection: UsuarioCollection - buscaUsuarioSessaoPorIdUseCase: BuscaUsuarioSessaoPorIdUseCase -} - -export class MostraSessaoUseCase { - private readonly accessToken: AccessToken - private readonly usuarioCollection: UsuarioCollection - private readonly buscaUsuarioSessaoPorIdUseCase: BuscaUsuarioSessaoPorIdUseCase - - constructor(dependencies: Dependencies) { - this.accessToken = dependencies.accessToken - this.usuarioCollection = dependencies.usuarioCollection - this.buscaUsuarioSessaoPorIdUseCase = dependencies.buscaUsuarioSessaoPorIdUseCase - } - - async execute(params: { accessToken: string }): Promise> { - const verified = this.accessToken.verify(params.accessToken) - if (verified.left()) { - return Either.left(new UserSessionNotFoundError({ cause: verified.value })) - } - - const session = await this.buscaUsuarioSessaoPorIdUseCase.execute({ id: verified.value.sid }) - if (session.left()) { - return session - } - if (!session.value) { - return Either.left(new UserSessionNotFoundError()) - } - - const usuario = await this.usuarioCollection.findById(verified.value.sub) - if (usuario.left()) { - return usuario - } - if (!usuario.value) { - return Either.left(new UserSessionNotFoundError()) - } - - return Either.right(usuario.value) - } -} diff --git a/src/domain/usuarioSessao/MostraUsuarioSessaoUseCase.ts b/src/domain/usuarioSessao/MostraUsuarioSessaoUseCase.ts new file mode 100644 index 00000000..24231ef0 --- /dev/null +++ b/src/domain/usuarioSessao/MostraUsuarioSessaoUseCase.ts @@ -0,0 +1,75 @@ +import { type Attributes as UsuarioAttributes } from '@/domain/usuario/Usuario' +import { type UsuarioCollection } from '@/domain/usuario/UsuarioCollection' +import { type AccessToken } from '@/library/auth/AccessToken' +import { Either } from '@/library/either/Either' + +import { UserSessionNotFoundError } from './error/UserSessionNotFoundError' +import { type Attributes, UsuarioSessao } from './UsuarioSessao' +import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' + +interface Dependencies { + accessToken: AccessToken + usuarioCollection: UsuarioCollection + usuarioSessaoCollection: UsuarioSessaoCollection + now?: () => Date +} + +export class MostraUsuarioSessaoUseCase { + private readonly accessToken: AccessToken + private readonly usuarioCollection: UsuarioCollection + private readonly usuarioSessaoCollection: UsuarioSessaoCollection + private readonly now: () => Date + + constructor(dependencies: Dependencies) { + this.accessToken = dependencies.accessToken + this.usuarioCollection = dependencies.usuarioCollection + this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection + this.now = dependencies.now ?? (() => new Date()) + } + + async execute(params: { accessToken: string }): Promise> { + const verified = this.accessToken.verify(params.accessToken) + if (verified.left()) { + return Either.left(new UserSessionNotFoundError({ cause: verified.value })) + } + + const found = await this.usuarioSessaoCollection.findById(verified.value.sid) + if (found.left()) { + return found + } + if (!found.value) { + return Either.left(new UserSessionNotFoundError()) + } + + const expired = await this.deleteIfExpired(found.value) + if (expired.left()) { + return expired + } + if (expired.value) { + return Either.left(new UserSessionNotFoundError()) + } + + const usuario = await this.usuarioCollection.findById(verified.value.sub) + if (usuario.left()) { + return usuario + } + if (!usuario.value) { + return Either.left(new UserSessionNotFoundError()) + } + + return Either.right(usuario.value.toAttributes()) + } + + private async deleteIfExpired(session: Attributes): Promise> { + if (!UsuarioSessao.expired(session.expiresAt, this.now())) { + return Either.right(false) + } + + const deleted = await this.usuarioSessaoCollection.deleteById(session.id) + if (deleted.left()) { + return deleted + } + + return Either.right(true) + } +} diff --git a/src/domain/usuarioSessao/RenovaSessaoUseCase.ts b/src/domain/usuarioSessao/RenovaSessaoUseCase.ts deleted file mode 100644 index d69a0b60..00000000 --- a/src/domain/usuarioSessao/RenovaSessaoUseCase.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { type UsuarioCollection } from '@/domain/usuario/UsuarioCollection' -import { type AccessToken } from '@/library/auth/AccessToken' -import { Either } from '@/library/either/Either' - -import { type ApagaUsuarioSessaoUseCase } from './ApagaUsuarioSessaoUseCase' -import { UserSessionNotFoundError } from './error/UserSessionNotFoundError' -import { type RotacionaUsuarioSessaoUseCase } from './RotacionaUsuarioSessaoUseCase' -import { type SessaoAutenticada } from './sessaoAutenticada' - -interface Dependencies { - usuarioCollection: UsuarioCollection - rotacionaUsuarioSessaoUseCase: RotacionaUsuarioSessaoUseCase - apagaUsuarioSessaoUseCase: ApagaUsuarioSessaoUseCase - accessToken: AccessToken -} - -export class RenovaSessaoUseCase { - private readonly usuarioCollection: UsuarioCollection - private readonly rotacionaUsuarioSessaoUseCase: RotacionaUsuarioSessaoUseCase - private readonly apagaUsuarioSessaoUseCase: ApagaUsuarioSessaoUseCase - private readonly accessToken: AccessToken - - constructor(dependencies: Dependencies) { - this.usuarioCollection = dependencies.usuarioCollection - this.rotacionaUsuarioSessaoUseCase = dependencies.rotacionaUsuarioSessaoUseCase - this.apagaUsuarioSessaoUseCase = dependencies.apagaUsuarioSessaoUseCase - this.accessToken = dependencies.accessToken - } - - async execute(params: { refreshToken: string }): Promise> { - const rotated = await this.rotacionaUsuarioSessaoUseCase.execute({ - refreshToken: params.refreshToken - }) - if (rotated.left()) { - return rotated - } - - const found = await this.usuarioCollection.findById(rotated.value.session.usuarioId) - if (found.left()) { - return found - } - if (!found.value) { - await this.apagaUsuarioSessaoUseCase.execute({ id: rotated.value.session.id }) - return Either.left(new UserSessionNotFoundError()) - } - - const signed = this.accessToken.sign({ - sub: found.value.id, - sid: rotated.value.session.id - }) - if (signed.left()) { - return signed - } - - return Either.right({ - accessToken: signed.value, - refreshToken: rotated.value.refreshToken, - user: found.value - }) - } -} diff --git a/src/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.ts b/src/domain/usuarioSessao/RenovaUsuarioSessaoUseCase.ts similarity index 61% rename from src/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.ts rename to src/domain/usuarioSessao/RenovaUsuarioSessaoUseCase.ts index 2d195fad..9ee26d82 100644 --- a/src/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.ts +++ b/src/domain/usuarioSessao/RenovaUsuarioSessaoUseCase.ts @@ -1,28 +1,37 @@ +import { type UsuarioCollection } from '@/domain/usuario/UsuarioCollection' +import { type AccessToken } from '@/library/auth/AccessToken' import { type RefreshToken } from '@/library/auth/RefreshToken' import { Either } from '@/library/either/Either' import { UserSessionNotFoundError } from './error/UserSessionNotFoundError' +import { type SessaoAutenticada } from './sessaoAutenticada' import { type Attributes, UsuarioSessao } from './UsuarioSessao' import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' interface Dependencies { + usuarioCollection: UsuarioCollection usuarioSessaoCollection: UsuarioSessaoCollection refreshToken: RefreshToken + accessToken: AccessToken now?: () => Date } -export class RotacionaUsuarioSessaoUseCase { +export class RenovaUsuarioSessaoUseCase { + private readonly usuarioCollection: UsuarioCollection private readonly usuarioSessaoCollection: UsuarioSessaoCollection private readonly refreshToken: RefreshToken + private readonly accessToken: AccessToken private readonly now: () => Date constructor(dependencies: Dependencies) { + this.usuarioCollection = dependencies.usuarioCollection this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection this.refreshToken = dependencies.refreshToken + this.accessToken = dependencies.accessToken this.now = dependencies.now ?? (() => new Date()) } - async execute(params: { refreshToken: string }): Promise> { + async execute(params: { refreshToken: string }): Promise> { const hashed = this.refreshToken.hash(params.refreshToken) if (hashed.left()) { return hashed @@ -36,8 +45,7 @@ export class RotacionaUsuarioSessaoUseCase { return Either.left(new UserSessionNotFoundError()) } - const now = this.now() - const expired = await this.deleteIfExpired(found.value, now) + const expired = await this.deleteIfExpired(found.value) if (expired.left()) { return expired } @@ -50,6 +58,7 @@ export class RotacionaUsuarioSessaoUseCase { return generated } + const now = this.now() const updated = await this.usuarioSessaoCollection.updateRotation(found.value.id, { refreshTokenHash: generated.value.hash, lastUsedAt: now, @@ -62,14 +71,32 @@ export class RotacionaUsuarioSessaoUseCase { return Either.left(new UserSessionNotFoundError()) } + const usuario = await this.usuarioCollection.findById(updated.value.usuarioId) + if (usuario.left()) { + return usuario + } + if (!usuario.value) { + await this.usuarioSessaoCollection.deleteById(updated.value.id) + return Either.left(new UserSessionNotFoundError()) + } + + const signed = this.accessToken.sign({ + sub: usuario.value.id, + sid: updated.value.id + }) + if (signed.left()) { + return signed + } + return Either.right({ - session: updated.value, - refreshToken: generated.value.token + accessToken: signed.value, + refreshToken: generated.value.token, + user: usuario.value.toAttributes() }) } - private async deleteIfExpired(session: Attributes, now: Date): Promise> { - if (!UsuarioSessao.expired(session.expiresAt, now)) { + private async deleteIfExpired(session: Attributes): Promise> { + if (!UsuarioSessao.expired(session.expiresAt, this.now())) { return Either.right(false) } diff --git a/src/domain/usuarioSessao/UsuarioSessao.ts b/src/domain/usuarioSessao/UsuarioSessao.ts index 6764dd7e..298525c1 100644 --- a/src/domain/usuarioSessao/UsuarioSessao.ts +++ b/src/domain/usuarioSessao/UsuarioSessao.ts @@ -59,4 +59,15 @@ export class UsuarioSessao { return Either.right(new UsuarioSessao(attributes)) } + + toAttributes(): Attributes { + return { + id: this.id, + usuarioId: this.usuarioId, + refreshTokenHash: this.refreshTokenHash, + createdAt: this.createdAt, + lastUsedAt: this.lastUsedAt, + expiresAt: this.expiresAt + } + } } diff --git a/src/factory/RateLimiterFactory.ts b/src/factory/RateLimiterFactory.ts index 07c0c0fa..90c6c50a 100644 --- a/src/factory/RateLimiterFactory.ts +++ b/src/factory/RateLimiterFactory.ts @@ -1,10 +1,17 @@ import { RateLimiterMemory } from 'rate-limiter-flexible' +import { RateLimitMiddleware } from '@/application/RateLimitMiddleware' +import { HttpError } from '@/library/http/error/HttpError' import { singleton } from '@/library/singleton' -export const createRateLimiter = singleton(() => { +const createRateLimiter = singleton(() => { return new RateLimiterMemory({ points: 5, duration: 15 * 60 }) }) + +export const rateLimitMiddleware = new RateLimitMiddleware({ + limiter: createRateLimiter(), + isFailure: response => response instanceof HttpError && response.statusCode === 401 +}) diff --git a/src/infrastructure/UsuarioCollectionKnexAdapter.ts b/src/infrastructure/UsuarioCollectionKnexAdapter.ts index 1eed6153..22e48c1b 100644 --- a/src/infrastructure/UsuarioCollectionKnexAdapter.ts +++ b/src/infrastructure/UsuarioCollectionKnexAdapter.ts @@ -1,8 +1,6 @@ import { type Knex } from 'knex' -import { - type Attributes, type AttributesComSenha, Usuario -} from '@/domain/usuario/Usuario' +import { Usuario } from '@/domain/usuario/Usuario' import { type UsuarioCollection } from '@/domain/usuario/UsuarioCollection' import { Either } from '@/library/either/Either' @@ -27,7 +25,7 @@ export class UsuarioCollectionKnexAdapter implements UsuarioCollection { this.knex = dependencies.knex } - async findByEmail(email: string): Promise> { + async findByEmail(email: string): Promise> { try { const row = await this.knex('usuarios') .select([ @@ -44,7 +42,13 @@ export class UsuarioCollectionKnexAdapter implements UsuarioCollection { return Either.right(null) } - return this.toComSenha(row) + return Usuario.create({ + id: Number(row.id), + nome: row.nome, + email: row.email, + tipoUsuarioId: Number(row.tipo_usuario_id), + senha: row.senha + }) } catch (error) { return Either.left(new CollectionError({ message: 'Failed to find usuário by email', @@ -53,7 +57,7 @@ export class UsuarioCollectionKnexAdapter implements UsuarioCollection { } } - async findById(id: number): Promise> { + async findById(id: number): Promise> { try { const row = await this.knex('usuarios') .select([ @@ -69,7 +73,12 @@ export class UsuarioCollectionKnexAdapter implements UsuarioCollection { return Either.right(null) } - return this.toAttributes(row) + return Usuario.create({ + id: Number(row.id), + nome: row.nome, + email: row.email, + tipoUsuarioId: Number(row.tipo_usuario_id) + }) } catch (error) { return Either.left(new CollectionError({ message: 'Failed to find usuário by id', @@ -77,44 +86,4 @@ export class UsuarioCollectionKnexAdapter implements UsuarioCollection { })) } } - - private toAttributes(row: UsuarioRow): Either { - const created = Usuario.create({ - id: Number(row.id), - nome: row.nome, - email: row.email, - tipoUsuarioId: Number(row.tipo_usuario_id) - }) - if (created.left()) { - return created - } - - return Either.right({ - id: created.value.id, - nome: created.value.nome, - email: created.value.email, - tipoUsuarioId: created.value.tipoUsuarioId - }) - } - - private toComSenha(row: UsuarioRow): Either { - const created = Usuario.create({ - id: Number(row.id), - nome: row.nome, - email: row.email, - tipoUsuarioId: Number(row.tipo_usuario_id), - senha: row.senha as string - }) - if (created.left()) { - return created - } - - return Either.right({ - id: created.value.id, - nome: created.value.nome, - email: created.value.email, - tipoUsuarioId: created.value.tipoUsuarioId, - senha: created.value.senha as string - }) - } } diff --git a/test/integration/usuarioSessao/sessao-http.test.ts b/test/integration/usuarioSessao/usuarioSessao.test.ts similarity index 100% rename from test/integration/usuarioSessao/sessao-http.test.ts rename to test/integration/usuarioSessao/usuarioSessao.test.ts diff --git a/test/unit/domain/usuario/FakeUsuarioCollection.ts b/test/unit/domain/usuario/FakeUsuarioCollection.ts index 07c7eb94..f742793a 100644 --- a/test/unit/domain/usuario/FakeUsuarioCollection.ts +++ b/test/unit/domain/usuario/FakeUsuarioCollection.ts @@ -1,28 +1,29 @@ -import { - type Attributes, type AttributesComSenha -} from '@/domain/usuario/Usuario' +import { type AttributesComSenha, Usuario } from '@/domain/usuario/Usuario' import { type UsuarioCollection } from '@/domain/usuario/UsuarioCollection' import { Either } from '@/library/either/Either' export class FakeUsuarioCollection implements UsuarioCollection { - readonly byId = new Map() + readonly byId = new Map() add(user: AttributesComSenha): void { - this.byId.set(user.id, { ...user }) + const created = Usuario.create(user) + if (created.right()) { + this.byId.set(user.id, created.value) + } } - findByEmail(email: string): Promise> { + findByEmail(email: string): Promise> { const row = [...this.byId.values()].find(user => user.email === email) - return Promise.resolve(Either.right(row ? { ...row } : null)) + return Promise.resolve(Either.right(row ?? null)) } - findById(id: number): Promise> { + findById(id: number): Promise> { const row = this.byId.get(id) if (!row) { return Promise.resolve(Either.right(null)) } - return Promise.resolve(Either.right({ + return Promise.resolve(Usuario.create({ id: row.id, nome: row.nome, email: row.email, diff --git a/test/unit/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.test.ts b/test/unit/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.test.ts deleted file mode 100644 index b9ebadfa..00000000 --- a/test/unit/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - describe, expect, test -} from 'vitest' - -import { ApagaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/ApagaUsuarioSessaoUseCase' - -import { - FakeUsuarioSessaoCollection, - makeSession -} from './FakeUsuarioSessaoCollection' - -describe('ApagaUsuarioSessaoUseCase', () => { - test('deletes the session by id', async () => { - const collection = new FakeUsuarioSessaoCollection() - const session = makeSession() - await collection.create(session) - - const useCase = new ApagaUsuarioSessaoUseCase({ usuarioSessaoCollection: collection }) - const result = await useCase.execute({ id: session.id }) - - expect(result.right()).toBe(true) - expect(collection.rows.has(session.id)).toBe(false) - }) - - test('succeeds when the id is already missing', async () => { - const collection = new FakeUsuarioSessaoCollection() - const useCase = new ApagaUsuarioSessaoUseCase({ usuarioSessaoCollection: collection }) - - const result = await useCase.execute({ id: '00000000-0000-4000-8000-000000000000' }) - - expect(result.right()).toBe(true) - }) -}) diff --git a/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.test.ts b/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.test.ts deleted file mode 100644 index 0497a4f6..00000000 --- a/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - describe, expect, test -} from 'vitest' - -import { BuscaUsuarioSessaoPorHashUseCase } from '@/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase' - -import { - FakeUsuarioSessaoCollection, - makeSession -} from './FakeUsuarioSessaoCollection' - -const NOW = new Date('2026-09-27T12:00:00.000Z') -const HASH = 'a'.repeat(64) - -describe('BuscaUsuarioSessaoPorHashUseCase', () => { - test('returns the session when it is still valid', async () => { - const collection = new FakeUsuarioSessaoCollection() - const session = makeSession({ - refreshTokenHash: HASH, - expiresAt: new Date('2026-10-01T00:00:00.000Z') - }) - await collection.create(session) - - const useCase = new BuscaUsuarioSessaoPorHashUseCase({ - usuarioSessaoCollection: collection, - now: () => NOW - }) - const result = await useCase.execute({ refreshTokenHash: HASH }) - - expect(result.right()).toBe(true) - expect(result.value).toMatchObject({ id: session.id }) - }) - - test('returns null when the hash is missing', async () => { - const useCase = new BuscaUsuarioSessaoPorHashUseCase({ - usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), - now: () => NOW - }) - - const result = await useCase.execute({ refreshTokenHash: '0'.repeat(64) }) - - expect(result.right()).toBe(true) - expect(result.value).toBeNull() - }) - - test('deletes an expired session and returns null', async () => { - const collection = new FakeUsuarioSessaoCollection() - const session = makeSession({ - refreshTokenHash: HASH, - expiresAt: new Date('2026-09-27T11:59:59.000Z') - }) - await collection.create(session) - - const useCase = new BuscaUsuarioSessaoPorHashUseCase({ - usuarioSessaoCollection: collection, - now: () => NOW - }) - const result = await useCase.execute({ refreshTokenHash: HASH }) - - expect(result.right()).toBe(true) - expect(result.value).toBeNull() - expect(collection.rows.has(session.id)).toBe(false) - }) -}) diff --git a/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.test.ts b/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.test.ts deleted file mode 100644 index 0953fa00..00000000 --- a/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - describe, expect, test -} from 'vitest' - -import { BuscaUsuarioSessaoPorIdUseCase } from '@/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase' - -import { - FakeUsuarioSessaoCollection, - makeSession -} from './FakeUsuarioSessaoCollection' - -const NOW = new Date('2026-09-27T12:00:00.000Z') - -describe('BuscaUsuarioSessaoPorIdUseCase', () => { - test('returns the session when it is still valid', async () => { - const collection = new FakeUsuarioSessaoCollection() - const session = makeSession({ expiresAt: new Date('2026-10-01T00:00:00.000Z') }) - await collection.create(session) - - const useCase = new BuscaUsuarioSessaoPorIdUseCase({ - usuarioSessaoCollection: collection, - now: () => NOW - }) - const result = await useCase.execute({ id: session.id }) - - expect(result.right()).toBe(true) - expect(result.value).toMatchObject({ id: session.id }) - }) - - test('returns null when the session is missing', async () => { - const useCase = new BuscaUsuarioSessaoPorIdUseCase({ - usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), - now: () => NOW - }) - - const result = await useCase.execute({ id: '00000000-0000-4000-8000-000000000000' }) - - expect(result.right()).toBe(true) - expect(result.value).toBeNull() - }) - - test('deletes an expired session and returns null', async () => { - const collection = new FakeUsuarioSessaoCollection() - const session = makeSession({ expiresAt: new Date('2026-09-27T11:59:59.000Z') }) - await collection.create(session) - - const useCase = new BuscaUsuarioSessaoPorIdUseCase({ - usuarioSessaoCollection: collection, - now: () => NOW - }) - const result = await useCase.execute({ id: session.id }) - - expect(result.right()).toBe(true) - expect(result.value).toBeNull() - expect(collection.rows.has(session.id)).toBe(false) - }) -}) diff --git a/test/unit/domain/usuarioSessao/CriaUsuarioSessaoUseCase.test.ts b/test/unit/domain/usuarioSessao/CriaUsuarioSessaoUseCase.test.ts index 0ceab1f3..96908fcc 100644 --- a/test/unit/domain/usuarioSessao/CriaUsuarioSessaoUseCase.test.ts +++ b/test/unit/domain/usuarioSessao/CriaUsuarioSessaoUseCase.test.ts @@ -2,12 +2,16 @@ import { describe, expect, test } from 'vitest' +import { InvalidCredentialsError } from '@/domain/usuario/error/InvalidCredentialsError' import { CriaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/CriaUsuarioSessaoUseCase' import { UsuarioSessao } from '@/domain/usuarioSessao/UsuarioSessao' +import { type AccessToken } from '@/library/auth/AccessToken' +import { AccessTokenInvalidError } from '@/library/auth/error/AccessTokenInvalidError' import { RefreshTokenError } from '@/library/auth/error/RefreshTokenError' import { type RefreshToken } from '@/library/auth/RefreshToken' import { Either } from '@/library/either/Either' +import { FakeUsuarioCollection } from '../usuario/FakeUsuarioCollection' import { FakeUsuarioSessaoCollection } from './FakeUsuarioSessaoCollection' const HASH = 'b'.repeat(64) @@ -24,44 +28,118 @@ function makeRefreshToken(): RefreshToken { } } +function makeAccessToken(): AccessToken { + return { + sign: params => Either.right(`access.${params.sub}.${params.sid}`), + verify: () => Either.left(new AccessTokenInvalidError()) + } +} + describe('CriaUsuarioSessaoUseCase', () => { - test('creates a session with generated id, hash, and 30-day expiry', async () => { - const collection = new FakeUsuarioSessaoCollection() + test('creates a session, signs access, and returns the user without a role claim', async () => { + const usuarioCollection = new FakeUsuarioCollection() + usuarioCollection.add({ + id: 7, + nome: 'Ana', + email: 'ana@example.test', + tipoUsuarioId: 2, + senha: 'hash' + }) + const sessaoCollection = new FakeUsuarioSessaoCollection() + const useCase = new CriaUsuarioSessaoUseCase({ - usuarioSessaoCollection: collection, + usuarioCollection, + usuarioSessaoCollection: sessaoCollection, refreshToken: makeRefreshToken(), + accessToken: makeAccessToken(), + comparaSenha: (texto, hash) => texto === 'secret' && hash === 'hash', now: () => NOW }) - const result = await useCase.execute({ usuarioId: 7 }) + const result = await useCase.execute({ + email: 'ana@example.test', + senha: 'secret' + }) expect(result.right()).toBe(true) - if (!result.right()) return + if (!result.right()) { + return + } expect(result.value.refreshToken).toBe(TOKEN) - expect(result.value.session.usuarioId).toBe(7) - expect(result.value.session.refreshTokenHash).toBe(HASH) - expect(result.value.session.id).toMatch( + expect(result.value.user).toEqual({ + id: 7, + nome: 'Ana', + email: 'ana@example.test', + tipoUsuarioId: 2 + }) + expect(result.value.accessToken.startsWith('access.7.')).toBe(true) + + const sid = result.value.accessToken.split('.')[2] + const stored = sessaoCollection.rows.get(sid) + expect(stored?.usuarioId).toBe(7) + expect(stored?.refreshTokenHash).toBe(HASH) + expect(stored?.id).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i ) - expect(result.value.session.createdAt).toEqual(NOW) - expect(result.value.session.lastUsedAt).toEqual(NOW) - expect(result.value.session.expiresAt).toEqual(UsuarioSessao.refreshExpiresAt(NOW)) - expect(collection.rows.get(result.value.session.id)?.refreshTokenHash).toBe(HASH) + expect(stored?.createdAt).toEqual(NOW) + expect(stored?.lastUsedAt).toEqual(NOW) + expect(stored?.expiresAt).toEqual(UsuarioSessao.refreshExpiresAt(NOW)) + }) + + test('rejects unknown email or bad password', async () => { + const usuarioCollection = new FakeUsuarioCollection() + usuarioCollection.add({ + id: 7, + nome: 'Ana', + email: 'ana@example.test', + tipoUsuarioId: 2, + senha: 'hash' + }) + + const useCase = new CriaUsuarioSessaoUseCase({ + usuarioCollection, + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + refreshToken: makeRefreshToken(), + accessToken: makeAccessToken(), + comparaSenha: () => false + }) + + const result = await useCase.execute({ + email: 'ana@example.test', + senha: 'wrong' + }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(InvalidCredentialsError) }) test('propagates refresh generate failure', async () => { + const usuarioCollection = new FakeUsuarioCollection() + usuarioCollection.add({ + id: 1, + nome: 'Ana', + email: 'ana@example.test', + tipoUsuarioId: 2, + senha: 'hash' + }) const error = new RefreshTokenError({ message: 'generate failed' }) const useCase = new CriaUsuarioSessaoUseCase({ + usuarioCollection, usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), refreshToken: { generate: () => Either.left(error), hash: () => Either.right(HASH) }, + accessToken: makeAccessToken(), + comparaSenha: () => true, now: () => NOW }) - const result = await useCase.execute({ usuarioId: 1 }) + const result = await useCase.execute({ + email: 'ana@example.test', + senha: 'secret' + }) expect(result.left()).toBe(true) expect(result.value).toBe(error) diff --git a/test/unit/domain/usuarioSessao/EncerraUsuarioSessaoUseCase.test.ts b/test/unit/domain/usuarioSessao/EncerraUsuarioSessaoUseCase.test.ts new file mode 100644 index 00000000..17b41514 --- /dev/null +++ b/test/unit/domain/usuarioSessao/EncerraUsuarioSessaoUseCase.test.ts @@ -0,0 +1,194 @@ +import { + describe, expect, test +} from 'vitest' + +import { EncerraUsuarioSessaoUseCase } from '@/domain/usuarioSessao/EncerraUsuarioSessaoUseCase' +import { UserSessionNotFoundError } from '@/domain/usuarioSessao/error/UserSessionNotFoundError' +import { type AccessToken } from '@/library/auth/AccessToken' +import { AccessTokenInvalidError } from '@/library/auth/error/AccessTokenInvalidError' +import { type RefreshToken } from '@/library/auth/RefreshToken' +import { Either } from '@/library/either/Either' + +import { + FakeUsuarioSessaoCollection, + makeSession +} from './FakeUsuarioSessaoCollection' + +const REFRESH = 'current-refresh' +const HASH = 'c'.repeat(64) +const SESSION_ID = '11111111-1111-4111-8111-111111111111' +const NOW = new Date('2026-09-27T12:00:00.000Z') + +function makeRefreshToken(): RefreshToken { + return { + generate: () => Either.right({ + token: 'unused', + hash: 'd'.repeat(64) + }), + hash: token => { + if (token === REFRESH) { + return Either.right(HASH) + } + return Either.right('0'.repeat(64)) + } + } +} + +function makeAccessToken(): AccessToken { + return { + sign: params => Either.right(`access.${params.sub}.${params.sid}`), + verify: token => { + if (token !== 'valid-access') { + return Either.left(new AccessTokenInvalidError()) + } + + return Either.right({ + sub: 3, + sid: SESSION_ID, + typ: 'access' as const, + iat: 0, + exp: 1 + }) + } + } +} + +describe('EncerraUsuarioSessaoUseCase', () => { + test('deletes the session by refresh token', async () => { + const collection = new FakeUsuarioSessaoCollection() + await collection.create(makeSession({ + id: SESSION_ID, + usuarioId: 3, + refreshTokenHash: HASH, + expiresAt: new Date('2026-10-01T00:00:00.000Z') + })) + + const useCase = new EncerraUsuarioSessaoUseCase({ + usuarioSessaoCollection: collection, + refreshToken: makeRefreshToken(), + accessToken: makeAccessToken(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshToken: REFRESH }) + + expect(result.right()).toBe(true) + expect(collection.rows.has(SESSION_ID)).toBe(false) + }) + + test('succeeds when the refresh token is already unknown', async () => { + const useCase = new EncerraUsuarioSessaoUseCase({ + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + refreshToken: makeRefreshToken(), + accessToken: makeAccessToken(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshToken: REFRESH }) + + expect(result.right()).toBe(true) + }) + + test('deletes an expired session found by refresh hash', async () => { + const collection = new FakeUsuarioSessaoCollection() + await collection.create(makeSession({ + id: SESSION_ID, + refreshTokenHash: HASH, + expiresAt: new Date('2026-09-27T11:59:59.000Z') + })) + + const useCase = new EncerraUsuarioSessaoUseCase({ + usuarioSessaoCollection: collection, + refreshToken: makeRefreshToken(), + accessToken: makeAccessToken(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshToken: REFRESH }) + + expect(result.right()).toBe(true) + expect(collection.rows.has(SESSION_ID)).toBe(false) + }) + + test('deletes the session by access token sid', async () => { + const collection = new FakeUsuarioSessaoCollection() + await collection.create(makeSession({ + id: SESSION_ID, + usuarioId: 3 + })) + + const useCase = new EncerraUsuarioSessaoUseCase({ + usuarioSessaoCollection: collection, + refreshToken: makeRefreshToken(), + accessToken: makeAccessToken(), + now: () => NOW + }) + + const result = await useCase.execute({ accessToken: 'valid-access' }) + + expect(result.right()).toBe(true) + expect(collection.rows.has(SESSION_ID)).toBe(false) + }) + + test('returns not found when access-only logout has an invalid token', async () => { + const useCase = new EncerraUsuarioSessaoUseCase({ + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + refreshToken: makeRefreshToken(), + accessToken: makeAccessToken(), + now: () => NOW + }) + + const result = await useCase.execute({ accessToken: 'bad' }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(UserSessionNotFoundError) + }) + + test('deletes every session for the user when all is requested', async () => { + const collection = new FakeUsuarioSessaoCollection() + await collection.create(makeSession({ + id: SESSION_ID, + usuarioId: 3 + })) + await collection.create(makeSession({ + id: '22222222-2222-4222-8222-222222222222', + usuarioId: 3, + refreshTokenHash: 'e'.repeat(64) + })) + await collection.create(makeSession({ + id: '33333333-3333-4333-8333-333333333333', + usuarioId: 9, + refreshTokenHash: 'f'.repeat(64) + })) + + const useCase = new EncerraUsuarioSessaoUseCase({ + usuarioSessaoCollection: collection, + refreshToken: makeRefreshToken(), + accessToken: makeAccessToken(), + now: () => NOW + }) + + const result = await useCase.execute({ + accessToken: 'valid-access', + all: true + }) + + expect(result.right()).toBe(true) + expect(collection.rows.size).toBe(1) + expect(collection.rows.has('33333333-3333-4333-8333-333333333333')).toBe(true) + }) + + test('returns not found when logout-all has no valid access token', async () => { + const useCase = new EncerraUsuarioSessaoUseCase({ + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + refreshToken: makeRefreshToken(), + accessToken: makeAccessToken(), + now: () => NOW + }) + + const result = await useCase.execute({ all: true }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(UserSessionNotFoundError) + }) +}) diff --git a/test/unit/domain/usuarioSessao/EntraSessaoUseCase.test.ts b/test/unit/domain/usuarioSessao/EntraSessaoUseCase.test.ts deleted file mode 100644 index bc0d7db6..00000000 --- a/test/unit/domain/usuarioSessao/EntraSessaoUseCase.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - describe, expect, test -} from 'vitest' - -import { CredenciaisInvalidasError } from '@/domain/usuario/error/CredenciaisInvalidasError' -import { CriaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/CriaUsuarioSessaoUseCase' -import { EntraSessaoUseCase } from '@/domain/usuarioSessao/EntraSessaoUseCase' -import { type AccessToken } from '@/library/auth/AccessToken' -import { AccessTokenInvalidError } from '@/library/auth/error/AccessTokenInvalidError' -import { type RefreshToken } from '@/library/auth/RefreshToken' -import { Either } from '@/library/either/Either' - -import { FakeUsuarioCollection } from '../usuario/FakeUsuarioCollection' -import { FakeUsuarioSessaoCollection } from './FakeUsuarioSessaoCollection' - -const HASH = 'b'.repeat(64) - -function makeRefreshToken(): RefreshToken { - return { - generate: () => Either.right({ - token: 'opaque-refresh', - hash: HASH - }), - hash: token => Either.right(`${token}-hashed`) - } -} - -function makeAccessToken(): AccessToken { - return { - sign: params => Either.right(`access.${params.sub}.${params.sid}`), - verify: () => Either.left(new AccessTokenInvalidError()) - } -} - -describe('EntraSessaoUseCase', () => { - test('creates a session and signs access without a role claim', async () => { - const usuarioCollection = new FakeUsuarioCollection() - usuarioCollection.add({ - id: 7, - nome: 'Ana', - email: 'ana@example.test', - tipoUsuarioId: 2, - senha: 'hash' - }) - - const useCase = new EntraSessaoUseCase({ - usuarioCollection, - criaUsuarioSessaoUseCase: new CriaUsuarioSessaoUseCase({ - usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), - refreshToken: makeRefreshToken() - }), - accessToken: makeAccessToken(), - comparaSenha: (texto, hash) => texto === 'secret' && hash === 'hash' - }) - - const result = await useCase.execute({ - email: 'ana@example.test', - senha: 'secret' - }) - - expect(result.right()).toBe(true) - if (!result.right()) { - return - } - - expect(result.value.refreshToken).toBe('opaque-refresh') - expect(result.value.user).toEqual({ - id: 7, - nome: 'Ana', - email: 'ana@example.test', - tipoUsuarioId: 2 - }) - expect(result.value.accessToken.startsWith('access.7.')).toBe(true) - }) - - test('rejects unknown email or bad password', async () => { - const usuarioCollection = new FakeUsuarioCollection() - usuarioCollection.add({ - id: 7, - nome: 'Ana', - email: 'ana@example.test', - tipoUsuarioId: 2, - senha: 'hash' - }) - - const useCase = new EntraSessaoUseCase({ - usuarioCollection, - criaUsuarioSessaoUseCase: new CriaUsuarioSessaoUseCase({ - usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), - refreshToken: makeRefreshToken() - }), - accessToken: makeAccessToken(), - comparaSenha: () => false - }) - - const result = await useCase.execute({ - email: 'ana@example.test', - senha: 'wrong' - }) - - expect(result.left()).toBe(true) - expect(result.value).toBeInstanceOf(CredenciaisInvalidasError) - }) -}) diff --git a/test/unit/domain/usuarioSessao/MostraUsuarioSessaoUseCase.test.ts b/test/unit/domain/usuarioSessao/MostraUsuarioSessaoUseCase.test.ts new file mode 100644 index 00000000..f9d72b27 --- /dev/null +++ b/test/unit/domain/usuarioSessao/MostraUsuarioSessaoUseCase.test.ts @@ -0,0 +1,124 @@ +import { + describe, expect, test +} from 'vitest' + +import { UserSessionNotFoundError } from '@/domain/usuarioSessao/error/UserSessionNotFoundError' +import { MostraUsuarioSessaoUseCase } from '@/domain/usuarioSessao/MostraUsuarioSessaoUseCase' +import { type AccessToken } from '@/library/auth/AccessToken' +import { AccessTokenInvalidError } from '@/library/auth/error/AccessTokenInvalidError' +import { Either } from '@/library/either/Either' + +import { FakeUsuarioCollection } from '../usuario/FakeUsuarioCollection' +import { + FakeUsuarioSessaoCollection, + makeSession +} from './FakeUsuarioSessaoCollection' + +const NOW = new Date('2026-09-27T12:00:00.000Z') +const SESSION_ID = '11111111-1111-4111-8111-111111111111' + +function makeAccessToken(): AccessToken { + return { + sign: params => Either.right(`access.${params.sub}.${params.sid}`), + verify: token => { + if (token !== 'valid-access') { + return Either.left(new AccessTokenInvalidError()) + } + + return Either.right({ + sub: 7, + sid: SESSION_ID, + typ: 'access' as const, + iat: 0, + exp: 1 + }) + } + } +} + +describe('MostraUsuarioSessaoUseCase', () => { + test('returns the user when the access token and session are valid', async () => { + const sessaoCollection = new FakeUsuarioSessaoCollection() + await sessaoCollection.create(makeSession({ + id: SESSION_ID, + usuarioId: 7, + expiresAt: new Date('2026-10-01T00:00:00.000Z') + })) + + const usuarioCollection = new FakeUsuarioCollection() + usuarioCollection.add({ + id: 7, + nome: 'Ana', + email: 'ana@example.test', + tipoUsuarioId: 2, + senha: 'hash' + }) + + const useCase = new MostraUsuarioSessaoUseCase({ + accessToken: makeAccessToken(), + usuarioCollection, + usuarioSessaoCollection: sessaoCollection, + now: () => NOW + }) + + const result = await useCase.execute({ accessToken: 'valid-access' }) + + expect(result.right()).toBe(true) + expect(result.value).toEqual({ + id: 7, + nome: 'Ana', + email: 'ana@example.test', + tipoUsuarioId: 2 + }) + }) + + test('returns not found when the access token is invalid', async () => { + const useCase = new MostraUsuarioSessaoUseCase({ + accessToken: makeAccessToken(), + usuarioCollection: new FakeUsuarioCollection(), + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + now: () => NOW + }) + + const result = await useCase.execute({ accessToken: 'bad' }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(UserSessionNotFoundError) + }) + + test('returns not found when the session is missing', async () => { + const useCase = new MostraUsuarioSessaoUseCase({ + accessToken: makeAccessToken(), + usuarioCollection: new FakeUsuarioCollection(), + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + now: () => NOW + }) + + const result = await useCase.execute({ accessToken: 'valid-access' }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(UserSessionNotFoundError) + }) + + test('deletes an expired session and returns not found', async () => { + const sessaoCollection = new FakeUsuarioSessaoCollection() + await sessaoCollection.create(makeSession({ + id: SESSION_ID, + usuarioId: 7, + expiresAt: new Date('2026-09-27T11:59:59.000Z') + })) + + const useCase = new MostraUsuarioSessaoUseCase({ + accessToken: makeAccessToken(), + usuarioCollection: new FakeUsuarioCollection(), + usuarioSessaoCollection: sessaoCollection, + now: () => NOW + }) + + const result = await useCase.execute({ accessToken: 'valid-access' }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(UserSessionNotFoundError) + expect(sessaoCollection.rows.has(SESSION_ID)).toBe(false) + }) +}) diff --git a/test/unit/domain/usuarioSessao/RenovaUsuarioSessaoUseCase.test.ts b/test/unit/domain/usuarioSessao/RenovaUsuarioSessaoUseCase.test.ts new file mode 100644 index 00000000..ec75e1d1 --- /dev/null +++ b/test/unit/domain/usuarioSessao/RenovaUsuarioSessaoUseCase.test.ts @@ -0,0 +1,157 @@ +import { + describe, expect, test +} from 'vitest' + +import { UserSessionNotFoundError } from '@/domain/usuarioSessao/error/UserSessionNotFoundError' +import { RenovaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/RenovaUsuarioSessaoUseCase' +import { UsuarioSessao } from '@/domain/usuarioSessao/UsuarioSessao' +import { type AccessToken } from '@/library/auth/AccessToken' +import { AccessTokenInvalidError } from '@/library/auth/error/AccessTokenInvalidError' +import { type RefreshToken } from '@/library/auth/RefreshToken' +import { Either } from '@/library/either/Either' + +import { FakeUsuarioCollection } from '../usuario/FakeUsuarioCollection' +import { + FakeUsuarioSessaoCollection, + makeSession +} from './FakeUsuarioSessaoCollection' + +const CURRENT_TOKEN = 'current-refresh' +const CURRENT_HASH = 'c'.repeat(64) +const NEW_TOKEN = 'new-refresh' +const NEW_HASH = 'd'.repeat(64) +const NOW = new Date('2026-09-27T12:00:00.000Z') + +function makeRefreshToken(): RefreshToken { + return { + generate: () => Either.right({ + token: NEW_TOKEN, + hash: NEW_HASH + }), + hash: token => { + if (token === CURRENT_TOKEN) { + return Either.right(CURRENT_HASH) + } + return Either.right('unknown'.padEnd(64, '0')) + } + } +} + +function makeAccessToken(): AccessToken { + return { + sign: params => Either.right(`access.${params.sub}.${params.sid}`), + verify: () => Either.left(new AccessTokenInvalidError()) + } +} + +describe('RenovaUsuarioSessaoUseCase', () => { + test('rotates the same session and signs a new access token', async () => { + const sessaoCollection = new FakeUsuarioSessaoCollection() + const existing = makeSession({ + usuarioId: 7, + refreshTokenHash: CURRENT_HASH, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + lastUsedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2026-10-01T00:00:00.000Z') + }) + await sessaoCollection.create(existing) + + const usuarioCollection = new FakeUsuarioCollection() + usuarioCollection.add({ + id: 7, + nome: 'Ana', + email: 'ana@example.test', + tipoUsuarioId: 2, + senha: 'hash' + }) + + const useCase = new RenovaUsuarioSessaoUseCase({ + usuarioCollection, + usuarioSessaoCollection: sessaoCollection, + refreshToken: makeRefreshToken(), + accessToken: makeAccessToken(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshToken: CURRENT_TOKEN }) + + expect(result.right()).toBe(true) + if (!result.right()) return + + expect(result.value.refreshToken).toBe(NEW_TOKEN) + expect(result.value.accessToken).toBe(`access.7.${existing.id}`) + expect(result.value.user).toEqual({ + id: 7, + nome: 'Ana', + email: 'ana@example.test', + tipoUsuarioId: 2 + }) + expect(sessaoCollection.rows.get(existing.id)?.refreshTokenHash).toBe(NEW_HASH) + expect(sessaoCollection.rows.get(existing.id)?.lastUsedAt).toEqual(NOW) + expect(sessaoCollection.rows.get(existing.id)?.expiresAt).toEqual( + UsuarioSessao.refreshExpiresAt(NOW) + ) + }) + + test('returns not found when the refresh hash is unknown', async () => { + const useCase = new RenovaUsuarioSessaoUseCase({ + usuarioCollection: new FakeUsuarioCollection(), + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + refreshToken: makeRefreshToken(), + accessToken: makeAccessToken(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshToken: 'missing' }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(UserSessionNotFoundError) + }) + + test('deletes an expired session and returns not found', async () => { + const sessaoCollection = new FakeUsuarioSessaoCollection() + const existing = makeSession({ + refreshTokenHash: CURRENT_HASH, + expiresAt: new Date('2026-09-27T11:00:00.000Z') + }) + await sessaoCollection.create(existing) + + const useCase = new RenovaUsuarioSessaoUseCase({ + usuarioCollection: new FakeUsuarioCollection(), + usuarioSessaoCollection: sessaoCollection, + refreshToken: makeRefreshToken(), + accessToken: makeAccessToken(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshToken: CURRENT_TOKEN }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(UserSessionNotFoundError) + expect(sessaoCollection.rows.has(existing.id)).toBe(false) + }) + + test('deletes the session when the user no longer exists', async () => { + const sessaoCollection = new FakeUsuarioSessaoCollection() + const existing = makeSession({ + usuarioId: 7, + refreshTokenHash: CURRENT_HASH, + expiresAt: new Date('2026-10-01T00:00:00.000Z') + }) + await sessaoCollection.create(existing) + + const useCase = new RenovaUsuarioSessaoUseCase({ + usuarioCollection: new FakeUsuarioCollection(), + usuarioSessaoCollection: sessaoCollection, + refreshToken: makeRefreshToken(), + accessToken: makeAccessToken(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshToken: CURRENT_TOKEN }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(UserSessionNotFoundError) + expect(sessaoCollection.rows.has(existing.id)).toBe(false) + }) +}) diff --git a/test/unit/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.test.ts b/test/unit/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.test.ts deleted file mode 100644 index 26de48d8..00000000 --- a/test/unit/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - describe, expect, test -} from 'vitest' - -import { UserSessionNotFoundError } from '@/domain/usuarioSessao/error/UserSessionNotFoundError' -import { RotacionaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase' -import { UsuarioSessao } from '@/domain/usuarioSessao/UsuarioSessao' -import { type RefreshToken } from '@/library/auth/RefreshToken' -import { Either } from '@/library/either/Either' - -import { - FakeUsuarioSessaoCollection, - makeSession -} from './FakeUsuarioSessaoCollection' - -const CURRENT_TOKEN = 'current-refresh' -const CURRENT_HASH = 'c'.repeat(64) -const NEW_TOKEN = 'new-refresh' -const NEW_HASH = 'd'.repeat(64) -const NOW = new Date('2026-09-27T12:00:00.000Z') - -function makeRefreshToken(): RefreshToken { - return { - generate: () => Either.right({ - token: NEW_TOKEN, - hash: NEW_HASH - }), - hash: token => { - if (token === CURRENT_TOKEN) { - return Either.right(CURRENT_HASH) - } - return Either.right('unknown'.padEnd(64, '0')) - } - } -} - -describe('RotacionaUsuarioSessaoUseCase', () => { - test('updates hash, lastUsedAt, and expiresAt on the same id', async () => { - const collection = new FakeUsuarioSessaoCollection() - const existing = makeSession({ - refreshTokenHash: CURRENT_HASH, - createdAt: new Date('2026-08-01T00:00:00.000Z'), - lastUsedAt: new Date('2026-08-01T00:00:00.000Z'), - expiresAt: new Date('2026-10-01T00:00:00.000Z') - }) - await collection.create(existing) - - const useCase = new RotacionaUsuarioSessaoUseCase({ - usuarioSessaoCollection: collection, - refreshToken: makeRefreshToken(), - now: () => NOW - }) - - const result = await useCase.execute({ refreshToken: CURRENT_TOKEN }) - - expect(result.right()).toBe(true) - if (!result.right()) return - - expect(result.value.refreshToken).toBe(NEW_TOKEN) - expect(result.value.session.id).toBe(existing.id) - expect(result.value.session.refreshTokenHash).toBe(NEW_HASH) - expect(result.value.session.lastUsedAt).toEqual(NOW) - expect(result.value.session.expiresAt).toEqual(UsuarioSessao.refreshExpiresAt(NOW)) - expect(collection.rows.get(existing.id)?.refreshTokenHash).toBe(NEW_HASH) - }) - - test('returns not found when the refresh hash is unknown', async () => { - const useCase = new RotacionaUsuarioSessaoUseCase({ - usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), - refreshToken: makeRefreshToken(), - now: () => NOW - }) - - const result = await useCase.execute({ refreshToken: 'missing' }) - - expect(result.left()).toBe(true) - expect(result.value).toBeInstanceOf(UserSessionNotFoundError) - }) - - test('deletes an expired session and returns not found', async () => { - const collection = new FakeUsuarioSessaoCollection() - const existing = makeSession({ - refreshTokenHash: CURRENT_HASH, - expiresAt: new Date('2026-09-27T11:00:00.000Z') - }) - await collection.create(existing) - - const useCase = new RotacionaUsuarioSessaoUseCase({ - usuarioSessaoCollection: collection, - refreshToken: makeRefreshToken(), - now: () => NOW - }) - - const result = await useCase.execute({ refreshToken: CURRENT_TOKEN }) - - expect(result.left()).toBe(true) - expect(result.value).toBeInstanceOf(UserSessionNotFoundError) - expect(collection.rows.has(existing.id)).toBe(false) - }) -}) diff --git a/test/unit/infrastructure/UsuarioCollectionKnexAdapter.test.ts b/test/unit/infrastructure/UsuarioCollectionKnexAdapter.test.ts index f57f9782..ee5c5a61 100644 --- a/test/unit/infrastructure/UsuarioCollectionKnexAdapter.test.ts +++ b/test/unit/infrastructure/UsuarioCollectionKnexAdapter.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test, vi } from 'vitest' +import { Usuario } from '@/domain/usuario/Usuario' import { UsuarioCollectionKnexAdapter } from '@/infrastructure/UsuarioCollectionKnexAdapter' const row = { @@ -38,7 +39,8 @@ describe('UsuarioCollectionKnexAdapter', () => { const result = await adapter.findByEmail('ana@example.test') expect(result.right()).toBe(true) - expect(result.value).toEqual({ + expect(result.value).toBeInstanceOf(Usuario) + expect(result.value).toMatchObject({ id: 4, nome: 'Ana', email: 'ana@example.test', @@ -61,12 +63,14 @@ describe('UsuarioCollectionKnexAdapter', () => { const result = await adapter.findById(4) expect(result.right()).toBe(true) - expect(result.value).toEqual({ + expect(result.value).toBeInstanceOf(Usuario) + expect(result.value).toMatchObject({ id: 4, nome: 'Ana', email: 'ana@example.test', tipoUsuarioId: 2 }) + expect(result.value).toMatchObject({ senha: undefined }) expect(knex.builder.where).toHaveBeenCalledWith({ id: 4 }) }) diff --git a/yarn.lock b/yarn.lock index 2942cf8d..e7f958c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4225,7 +4225,7 @@ range-parser@^1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== -rate-limiter-flexible@^11.2.1: +rate-limiter-flexible@11.2.1: version "11.2.1" resolved "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-11.2.1.tgz#1d0518f7a118e017eb7dfd2bb818942fbd2b2764" integrity sha512-JAaz01HZ893zAw+Hx5MdM1z3lLAkyVvNpqR+GNp0k3kjuQB8gY91fXhf5C0osWs+A7iKgFv585qzwmjS3aBHlA== From f3d3b3b58aeb0e4de7ab6581372c702d54c5114e Mon Sep 17 00:00:00 2001 From: Edvaldo Szymonek Date: Mon, 28 Sep 2026 21:50:02 -0300 Subject: [PATCH 3/4] =?UTF-8?q?adiciona=20regra=20de=20agente=20para=20cri?= =?UTF-8?q?a=C3=A7=C3=A3o=20de=20use=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/use-case-boundaries.mdc | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .cursor/rules/use-case-boundaries.mdc diff --git a/.cursor/rules/use-case-boundaries.mdc b/.cursor/rules/use-case-boundaries.mdc new file mode 100644 index 00000000..632615fb --- /dev/null +++ b/.cursor/rules/use-case-boundaries.mdc @@ -0,0 +1,44 @@ +--- +description: Use cases model a full user action, not a SQL verb, and do not import other use cases +globs: "{src,test}/{domain,application}/**/*.{ts,js}" +alwaysApply: false +--- + +# Use case boundaries + +A use case is one **user intention** end to end (login, renew a session, end a session, show the current user). It orchestrates ports (collections, token helpers, comparators) and entities. It is not a thin wrapper around one SQL method. + +## Do + +- Fold credential checks, entity creation, persistence, and token issuance into the **same** use case when they belong to one action. +- Depend on collection ports, libraries, and other domain types — never on another `*UseCase`. +- Keep controllers as HTTP adapters: parse body/headers/cookies, set `Set-Cookie`, map `Either` to status codes. No persist, hash, or `sign` in the controller. +- Name the type after the intention and the resource (`CriaUsuarioSessaoUseCase`), same inflection as `type-and-file-names`. + +## Do not + +- Add a use case whose `execute` only calls `create`, `findById`, `updateRotation`, or `deleteById`. Those stay on the collection. +- Split “create the row” and “do the action” into two use cases, then nest them. +- Import or inject `FooUseCase` inside `BarUseCase`. If two flows share a persistence step, share the **port** (or a small entity helper), not a use case. + +```ts +// ❌ BAD — SQL-only collaborator + nested use case +class CriaUsuarioSessaoUseCase { + execute({ usuarioId }) { return this.collection.create(/* row */) } +} +class EntraSessaoUseCase { + execute({ email, senha }) { + /* check password */ + return this.criaUsuarioSessaoUseCase.execute({ usuarioId }) + } +} + +// ✅ GOOD — one intention, ports only +class CriaUsuarioSessaoUseCase { + execute({ email, senha }) { + /* find user, check password, persist session, generate refresh, sign access */ + } +} +``` + +`ApagaUsuarioSessoesUseCase` (wipe every session for a user from another flow) is still a user intention. `ApagaUsuarioSessaoUseCase` that only `deleteById` is not — inlined that delete in the flow that needs it. From ba2f5debe6be202b1dbdbaba693557eee0b642e2 Mon Sep 17 00:00:00 2001 From: Edvaldo Szymonek Date: Mon, 28 Sep 2026 22:00:09 -0300 Subject: [PATCH 4/4] =?UTF-8?q?corrige=20teste=20de=20integra=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pull_request.yml | 2 ++ test/integration/setup/app-factory.ts | 15 +++++++++++++-- test/integration/setup/load-env.ts | 4 ++++ .../usuarioSessao/usuarioSessao.test.ts | 4 +++- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 79c61cc1..069539b4 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -48,6 +48,8 @@ jobs: PG_DATABASE: herbario_test PG_USERNAME: postgres PG_PASSWORD: secret + JWT_SECRET: test-jwt-secret-for-auth-sessions + CORS_ORIGINS: http://localhost:5173 services: postgres: image: postgis/postgis:18-3.6 diff --git a/test/integration/setup/app-factory.ts b/test/integration/setup/app-factory.ts index aa773e63..2eb0a7f4 100644 --- a/test/integration/setup/app-factory.ts +++ b/test/integration/setup/app-factory.ts @@ -24,8 +24,19 @@ export function createTestApp() { logger: new ConsoleLogger(), cors: { origins: ['http://localhost:5173'], - methods: ['HEAD', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'], - allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'] + methods: [ + 'HEAD', + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE' + ], + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'X-Requested-With' + ] } }) diff --git a/test/integration/setup/load-env.ts b/test/integration/setup/load-env.ts index a80f0212..71e0e7d2 100644 --- a/test/integration/setup/load-env.ts +++ b/test/integration/setup/load-env.ts @@ -8,4 +8,8 @@ try { // In CI, environment variables are injected directly into the process } +if (!process.env.JWT_SECRET) { + process.env.JWT_SECRET = 'test-jwt-secret-for-auth-sessions' +} + mkdirSync(path.resolve(process.cwd(), 'uploads'), { recursive: true }) diff --git a/test/integration/usuarioSessao/usuarioSessao.test.ts b/test/integration/usuarioSessao/usuarioSessao.test.ts index 7b35e10d..356a1371 100644 --- a/test/integration/usuarioSessao/usuarioSessao.test.ts +++ b/test/integration/usuarioSessao/usuarioSessao.test.ts @@ -128,7 +128,9 @@ describe('usuario sessao HTTP', () => { const firstRefresh = refreshFromSetCookie(login.headers['set-cookie']) expect(firstRefresh).toBe(loginBody.refresh_token) expect(cookieHeader(login.headers['set-cookie'])).toContain('HttpOnly') - expect(cookieHeader(login.headers['set-cookie'])).toContain('SameSite=None') + expect(cookieHeader(login.headers['set-cookie'])).toContain('SameSite=Lax') + expect(cookieHeader(login.headers['set-cookie'])).not.toContain('SameSite=None') + expect(cookieHeader(login.headers['set-cookie'])).not.toContain('Secure') expect(cookieHeader(login.headers['set-cookie'])).toContain('Path=/api/auth') const me = await agent