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..d893439c 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..cadcf970 --- /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, + notAuthorized, + readBearerAccess, + resolveRefreshToken +} from './sessaoHttp' + +interface Dependencies { + refreshToken: RefreshToken + accessToken: AccessToken + buscaUsuarioSessaoPorHashUseCase: BuscaUsuarioSessaoPorHashUseCase + apagaUsuarioSessaoUseCase: ApagaUsuarioSessaoUseCase + apagaUsuarioSessoesUseCase: ApagaUsuarioSessoesUseCase +} + +export class EncerraSessaoController implements RequestHandler { + private readonly refreshToken: RefreshToken + private readonly accessToken: AccessToken + private readonly buscaUsuarioSessaoPorHashUseCase: BuscaUsuarioSessaoPorHashUseCase + private readonly apagaUsuarioSessaoUseCase: ApagaUsuarioSessaoUseCase + private readonly apagaUsuarioSessoesUseCase: ApagaUsuarioSessoesUseCase + + constructor(dependencies: Dependencies) { + this.refreshToken = dependencies.refreshToken + this.accessToken = dependencies.accessToken + this.buscaUsuarioSessaoPorHashUseCase = dependencies.buscaUsuarioSessaoPorHashUseCase + this.apagaUsuarioSessaoUseCase = dependencies.apagaUsuarioSessaoUseCase + this.apagaUsuarioSessoesUseCase = dependencies.apagaUsuarioSessoesUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const refresh = resolveRefreshToken(request) + const access = readBearerAccess(request) + const verifiedAccess = access ? this.accessToken.verify(access) : undefined + + if ( + refresh?.cookieOnly + && looksLikeBrowserRequest(request) + && !hasCsrfHeader(request) + && !verifiedAccess?.right() + ) { + return notAuthorized() + } + + if (logoutAllRequested(request.body)) { + if (!verifiedAccess || verifiedAccess.left()) { + return this.cleared(notAuthorized()) + } + + const deletedAll = await this.apagaUsuarioSessoesUseCase.execute({ + usuarioId: verifiedAccess.value.sub + }) + if (deletedAll.left()) { + return new InternalServerError({ message: deletedAll.value.message }) + } + + return this.cleared({ statusCode: StatusCode.NoContent }) + } + + if (refresh) { + const hashed = this.refreshToken.hash(refresh.token) + if (hashed.left()) { + return this.cleared(notAuthorized()) + } + + const found = await this.buscaUsuarioSessaoPorHashUseCase.execute({ + refreshTokenHash: hashed.value + }) + if (found.left()) { + return new InternalServerError({ message: found.value.message }) + } + if (found.value) { + const deleted = await this.apagaUsuarioSessaoUseCase.execute({ id: found.value.id }) + if (deleted.left()) { + return new InternalServerError({ message: deleted.value.message }) + } + } + + return this.cleared({ statusCode: StatusCode.NoContent }) + } + + if (!verifiedAccess || verifiedAccess.left()) { + return this.cleared(notAuthorized()) + } + + const deleted = await this.apagaUsuarioSessaoUseCase.execute({ id: verifiedAccess.value.sid }) + if (deleted.left()) { + return new InternalServerError({ message: deleted.value.message }) + } + + return this.cleared({ statusCode: StatusCode.NoContent }) + } + + private cleared(response: HttpResponse | HttpError): HttpResponse | HttpError { + if (response instanceof HttpError) { + return response + } + + return { + ...response, + headers: { + ...response.headers, + 'Set-Cookie': serializeClearedRefreshCookie() + } + } + } +} diff --git a/src/application/usuarioSessao/EntraSessaoController.ts b/src/application/usuarioSessao/EntraSessaoController.ts new file mode 100644 index 00000000..327caf5d --- /dev/null +++ b/src/application/usuarioSessao/EntraSessaoController.ts @@ -0,0 +1,43 @@ +import { InvalidCredentialsError } from '@/domain/usuario/error/InvalidCredentialsError' +import { type EntraSessaoUseCase } from '@/domain/usuarioSessao/EntraSessaoUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { type NextHandler, type RequestHandler } from '@/library/http/Server' + +import { serializeRefreshCookie } from './refreshCookie' +import { credenciaisInvalidas, sessaoResponseBody } from './sessaoHttp' + +interface Dependencies { + entraSessaoUseCase: EntraSessaoUseCase +} + +export class EntraSessaoController implements RequestHandler { + private readonly entraSessaoUseCase: EntraSessaoUseCase + + constructor(dependencies: Dependencies) { + this.entraSessaoUseCase = dependencies.entraSessaoUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const body = request.body as { email?: unknown; senha?: unknown } + const email = typeof body?.email === 'string' ? body.email : '' + const senha = typeof body?.senha === 'string' ? body.senha : '' + + const result = await this.entraSessaoUseCase.execute({ email, senha }) + if (result.left()) { + if (result.value instanceof InvalidCredentialsError) { + return credenciaisInvalidas() + } + return new InternalServerError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.Ok, + headers: { 'Set-Cookie': serializeRefreshCookie(result.value.refreshToken) }, + body: sessaoResponseBody(result.value) + } + } +} diff --git a/src/application/usuarioSessao/MostraSessaoController.ts b/src/application/usuarioSessao/MostraSessaoController.ts new file mode 100644 index 00000000..98f32de7 --- /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, notAuthorized, readBearerAccess +} from './sessaoHttp' + +interface Dependencies { + mostraSessaoUseCase: MostraSessaoUseCase +} + +export class MostraSessaoController implements RequestHandler { + private readonly mostraSessaoUseCase: MostraSessaoUseCase + + constructor(dependencies: Dependencies) { + this.mostraSessaoUseCase = dependencies.mostraSessaoUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const token = readBearerAccess(request) + if (!token) { + return notAuthorized() + } + + const result = await this.mostraSessaoUseCase.execute({ accessToken: token }) + if (result.left()) { + if (result.value instanceof UserSessionNotFoundError) { + return notAuthorized() + } + return new InternalServerError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.Ok, + body: meResponseBody(result.value) + } + } +} diff --git a/src/application/usuarioSessao/RenovaSessaoController.ts b/src/application/usuarioSessao/RenovaSessaoController.ts new file mode 100644 index 00000000..1b1d1420 --- /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, notAuthorized, 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 notAuthorized() + } + if (refresh.cookieOnly && looksLikeBrowserRequest(request) && !hasCsrfHeader(request)) { + return notAuthorized() + } + + const result = await this.renovaSessaoUseCase.execute({ refreshToken: refresh.token }) + if (result.left()) { + if (result.value instanceof UserSessionNotFoundError) { + return notAuthorized() + } + 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..2dec6947 --- /dev/null +++ b/src/application/usuarioSessao/index.ts @@ -0,0 +1,106 @@ +import { type Knex } from 'knex' + +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 { 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 { 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: [ + rateLimitMiddleware, + 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..039626b6 --- /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 notAuthorized(): UnauthorizedError { + return new UnauthorizedError({ message: 'Unauthorized' }) +} + +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..0c55f6f3 --- /dev/null +++ b/src/domain/usuario/Usuario.ts @@ -0,0 +1,53 @@ +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)) + } + + 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 new file mode 100644 index 00000000..a3f741db --- /dev/null +++ b/src/domain/usuario/UsuarioCollection.ts @@ -0,0 +1,8 @@ +import { type Either } from '@/library/either/Either' + +import { type Usuario } from './Usuario' + +export interface UsuarioCollection { + findByEmail(email: string): Promise> + findById(id: number): Promise> +} diff --git a/src/domain/usuario/error/InvalidCredentialsError.ts b/src/domain/usuario/error/InvalidCredentialsError.ts new file mode 100644 index 00000000..b550bf6a --- /dev/null +++ b/src/domain/usuario/error/InvalidCredentialsError.ts @@ -0,0 +1,10 @@ +import { BaseError } from '@/library/BaseError' + +export class InvalidCredentialsError extends BaseError { + constructor(params?: { cause?: unknown }) { + super({ + message: 'Credenciais inválidas', + cause: params?.cause + }) + } +} diff --git a/src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts b/src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts index 11b59d47..6ad5f057 100644 --- a/src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts +++ b/src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts @@ -44,14 +44,7 @@ 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 } diff --git a/src/domain/usuarioSessao/EntraSessaoUseCase.ts b/src/domain/usuarioSessao/EntraSessaoUseCase.ts new file mode 100644 index 00000000..0bd55f74 --- /dev/null +++ b/src/domain/usuarioSessao/EntraSessaoUseCase.ts @@ -0,0 +1,57 @@ +import { InvalidCredentialsError } from '@/domain/usuario/error/InvalidCredentialsError' +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?.senha || !this.comparaSenha(params.senha, found.value.senha)) { + return Either.left(new InvalidCredentialsError()) + } + + 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: found.value.toAttributes() + }) + } +} diff --git a/src/domain/usuarioSessao/MostraSessaoUseCase.ts b/src/domain/usuarioSessao/MostraSessaoUseCase.ts new file mode 100644 index 00000000..0d08a59b --- /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.toAttributes()) + } +} diff --git a/src/domain/usuarioSessao/RenovaSessaoUseCase.ts b/src/domain/usuarioSessao/RenovaSessaoUseCase.ts new file mode 100644 index 00000000..4bcd7e9c --- /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.toAttributes() + }) + } +} diff --git a/src/domain/usuarioSessao/UsuarioSessao.ts b/src/domain/usuarioSessao/UsuarioSessao.ts index 7e64eb7a..298525c1 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')) } @@ -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/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..90c6c50a --- /dev/null +++ b/src/factory/RateLimiterFactory.ts @@ -0,0 +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' + +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/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..22e48c1b --- /dev/null +++ b/src/infrastructure/UsuarioCollectionKnexAdapter.ts @@ -0,0 +1,89 @@ +import { type Knex } from 'knex' + +import { 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 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', + 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 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', + cause: error + })) + } + } +} 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/usuarioSessao.test.ts b/test/integration/usuarioSessao/usuarioSessao.test.ts new file mode 100644 index 00000000..7b35e10d --- /dev/null +++ b/test/integration/usuarioSessao/usuarioSessao.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..f742793a --- /dev/null +++ b/test/unit/domain/usuario/FakeUsuarioCollection.ts @@ -0,0 +1,33 @@ +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() + + add(user: AttributesComSenha): void { + const created = Usuario.create(user) + if (created.right()) { + this.byId.set(user.id, created.value) + } + } + + findByEmail(email: string): Promise> { + const row = [...this.byId.values()].find(user => user.email === email) + return Promise.resolve(Either.right(row ?? null)) + } + + findById(id: number): Promise> { + const row = this.byId.get(id) + if (!row) { + return Promise.resolve(Either.right(null)) + } + + return Promise.resolve(Usuario.create({ + 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..4b8c3df9 --- /dev/null +++ b/test/unit/domain/usuarioSessao/EntraSessaoUseCase.test.ts @@ -0,0 +1,104 @@ +import { + describe, expect, test +} from 'vitest' + +import { InvalidCredentialsError } from '@/domain/usuario/error/InvalidCredentialsError' +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(InvalidCredentialsError) + }) +}) diff --git a/test/unit/infrastructure/UsuarioCollectionKnexAdapter.test.ts b/test/unit/infrastructure/UsuarioCollectionKnexAdapter.test.ts new file mode 100644 index 00000000..ee5c5a61 --- /dev/null +++ b/test/unit/infrastructure/UsuarioCollectionKnexAdapter.test.ts @@ -0,0 +1,86 @@ +import type { Knex } from 'knex' +import { + describe, expect, test, vi +} from 'vitest' + +import { Usuario } from '@/domain/usuario/Usuario' +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).toBeInstanceOf(Usuario) + expect(result.value).toMatchObject({ + 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).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 }) + }) + + 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..e7f958c0 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"