mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
Compare commits
4 Commits
85f6dcfc22
...
v2.3.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43697b4fd2 | ||
|
|
27e3ba5f0d | ||
|
|
31485eec8f | ||
|
|
3be64aa8d0 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -7,6 +7,30 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.3.5] - 2026-04-07
|
||||
|
||||
### Corrigido
|
||||
|
||||
- CSP: movido `Content-Security-Policy` do `next.config.ts` (build time) para `proxy.ts` (runtime), corrigindo bloqueio de upload de anexos quando `S3_ENDPOINT` não estava disponível durante o build do Docker
|
||||
|
||||
## [2.3.4] - 2026-04-05
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Anexos: corrigido upload que falhava com `NetworkError` — CSP `connect-src` bloqueava fetch para o Storage
|
||||
|
||||
## [2.3.3] - 2026-04-05
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Tokens: corrigido `/api/auth/device/verify` que rejeitava tokens criados via Settings (revertido de JWT para hash lookup)
|
||||
|
||||
### Alterado
|
||||
|
||||
- Tokens: prefixo renomeado de `os_` para `opm_` (OpenMonetis); tokens existentes precisam ser recriados
|
||||
- Tokens: removidas rotas JWT não utilizadas (`/api/auth/device/token` e `/api/auth/device/refresh`)
|
||||
- Tokens: `api-token.ts` simplificado para conter apenas `hashToken` e `extractBearerToken`
|
||||
|
||||
## [2.3.2] - 2026-04-04
|
||||
|
||||
### Segurança
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
|
||||
@@ -4,8 +4,6 @@ import type { NextConfig } from "next";
|
||||
// Carregar variáveis de ambiente explicitamente
|
||||
dotenv.config();
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
cacheComponents: true,
|
||||
@@ -44,18 +42,6 @@ const nextConfig: NextConfig = {
|
||||
key: "X-Frame-Options",
|
||||
value: "DENY",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://umami.felipecoutinho.com`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' https://lh3.googleusercontent.com data: blob:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self' https://umami.felipecoutinho.com",
|
||||
"frame-ancestors 'none'",
|
||||
].join("; "),
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.3.2",
|
||||
"version": "2.3.5",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import {
|
||||
extractBearerToken,
|
||||
refreshAccessToken,
|
||||
verifyJwt,
|
||||
} from "@/shared/lib/auth/api-token";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Extrair refresh token do header
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const token = extractBearerToken(authHeader);
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Refresh token não fornecido" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validar refresh token
|
||||
const payload = verifyJwt(token);
|
||||
|
||||
if (!payload || payload.type !== "api_refresh") {
|
||||
return NextResponse.json(
|
||||
{ error: "Refresh token inválido ou expirado" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verificar se token não foi revogado
|
||||
const tokenRecord = await db.query.apiTokens.findFirst({
|
||||
where: and(
|
||||
eq(apiTokens.id, payload.tokenId),
|
||||
eq(apiTokens.userId, payload.sub),
|
||||
isNull(apiTokens.revokedAt),
|
||||
gt(apiTokens.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
if (!tokenRecord) {
|
||||
return NextResponse.json(
|
||||
{ error: "Token revogado ou não encontrado" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Gerar novo access token
|
||||
const result = refreshAccessToken(token);
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não foi possível renovar o token" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Atualizar último uso e expiração (sem sobrescrever tokenHash,
|
||||
// pois o JWT é auto-verificável por assinatura)
|
||||
await db
|
||||
.update(apiTokens)
|
||||
.set({
|
||||
lastUsedAt: new Date(),
|
||||
lastUsedIp:
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
null,
|
||||
expiresAt: result.expiresAt,
|
||||
})
|
||||
.where(eq(apiTokens.id, payload.tokenId));
|
||||
|
||||
return NextResponse.json({
|
||||
accessToken: result.accessToken,
|
||||
expiresAt: result.expiresAt.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API] Error refreshing device token:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro ao renovar token" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { headers } from "next/headers";
|
||||
import { connection, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import {
|
||||
generateTokenPair,
|
||||
getTokenPrefix,
|
||||
hashToken,
|
||||
} from "@/shared/lib/auth/api-token";
|
||||
import { auth } from "@/shared/lib/auth/config";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
const createTokenSchema = z.object({
|
||||
name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
|
||||
deviceId: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
await connection();
|
||||
|
||||
// Verificar autenticação via sessão web
|
||||
const requestHeaders = new Headers(await headers());
|
||||
const session = await auth.api.getSession({ headers: requestHeaders });
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Validar body
|
||||
const body = await request.json();
|
||||
const { name, deviceId } = createTokenSchema.parse(body);
|
||||
|
||||
// Gerar par de tokens
|
||||
const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
|
||||
session.user.id,
|
||||
deviceId,
|
||||
);
|
||||
|
||||
// Salvar hash do token no banco
|
||||
await db.insert(apiTokens).values({
|
||||
id: tokenId,
|
||||
userId: session.user.id,
|
||||
name,
|
||||
tokenHash: hashToken(accessToken),
|
||||
tokenPrefix: getTokenPrefix(accessToken),
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Retornar tokens (mostrados apenas uma vez)
|
||||
return NextResponse.json(
|
||||
{
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenId,
|
||||
name,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
message:
|
||||
"Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.issues[0]?.message ?? "Dados inválidos" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[API] Error creating device token:", error);
|
||||
return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { extractBearerToken, verifyJwt } from "@/shared/lib/auth/api-token";
|
||||
import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -17,21 +17,20 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Verificar JWT (assinatura + expiração)
|
||||
const payload = verifyJwt(token);
|
||||
|
||||
if (!payload || payload.type !== "api_access") {
|
||||
// Validar token opm_xxx via hash
|
||||
if (!token.startsWith("opm_")) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Token inválido ou expirado" },
|
||||
{ valid: false, error: "Formato de token inválido" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Buscar token no banco por tokenId para checar revogação
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
// Buscar token no banco
|
||||
const tokenRecord = await db.query.apiTokens.findFirst({
|
||||
where: and(
|
||||
eq(apiTokens.id, payload.tokenId),
|
||||
eq(apiTokens.userId, payload.sub),
|
||||
eq(apiTokens.tokenHash, tokenHash),
|
||||
isNull(apiTokens.revokedAt),
|
||||
gt(apiTokens.expiresAt, new Date()),
|
||||
),
|
||||
@@ -39,7 +38,7 @@ export async function POST(request: Request) {
|
||||
|
||||
if (!tokenRecord) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Token revogado ou não encontrado" },
|
||||
{ valid: false, error: "Token inválido ou revogado" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validar token os_xxx via hash
|
||||
if (!token.startsWith("os_")) {
|
||||
// Validar token opm_xxx via hash
|
||||
if (!token.startsWith("opm_")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Formato de token inválido" },
|
||||
{ status: 401 },
|
||||
|
||||
@@ -41,8 +41,8 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validar token os_xxx via hash
|
||||
if (!token.startsWith("os_")) {
|
||||
// Validar token opm_xxx via hash
|
||||
if (!token.startsWith("opm_")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Formato de token inválido" },
|
||||
{ status: 401 },
|
||||
|
||||
@@ -610,7 +610,7 @@ const revokeApiTokenSchema = z.object({
|
||||
});
|
||||
|
||||
function generateSecureToken(): string {
|
||||
const prefix = "os";
|
||||
const prefix = "opm";
|
||||
const randomPart = randomBytes(32).toString("base64url");
|
||||
return `${prefix}_${randomPart}`;
|
||||
}
|
||||
|
||||
36
src/proxy.ts
36
src/proxy.ts
@@ -21,6 +21,38 @@ const PROTECTED_ROUTES = [
|
||||
// Rotas públicas (não requerem autenticação)
|
||||
const PUBLIC_AUTH_ROUTES = ["/login", "/signup"];
|
||||
|
||||
function buildCsp(): string {
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
const s3Origin = (() => {
|
||||
try {
|
||||
return process.env.S3_ENDPOINT
|
||||
? new URL(process.env.S3_ENDPOINT).origin
|
||||
: "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
|
||||
const connectExtras = ["https://umami.felipecoutinho.com", s3Origin]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const imgExtras = ["https://lh3.googleusercontent.com", s3Origin]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://umami.felipecoutinho.com`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
`img-src 'self' ${imgExtras} data: blob:`,
|
||||
"font-src 'self'",
|
||||
`connect-src 'self' ${connectExtras}`,
|
||||
"frame-ancestors 'none'",
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
export default async function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
@@ -63,7 +95,9 @@ export default async function proxy(request: NextRequest) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
const response = NextResponse.next();
|
||||
response.headers.set("Content-Security-Policy", buildCsp());
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -1,149 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
function getJwtSecret(): string {
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
"BETTER_AUTH_SECRET is required. Set it in your .env file.",
|
||||
);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
const ACCESS_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days in seconds
|
||||
const REFRESH_TOKEN_EXPIRY = 90 * 24 * 60 * 60; // 90 days in seconds
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string; // userId
|
||||
type: "api_access" | "api_refresh";
|
||||
tokenId: string;
|
||||
deviceId?: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
tokenId: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JWT UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base64URL encode a string
|
||||
*/
|
||||
function base64UrlEncode(str: string): string {
|
||||
return Buffer.from(str)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64URL decode a string
|
||||
*/
|
||||
function base64UrlDecode(str: string): string {
|
||||
str = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const pad = str.length % 4;
|
||||
if (pad) {
|
||||
str += "=".repeat(4 - pad);
|
||||
}
|
||||
return Buffer.from(str, "base64").toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HMAC-SHA256 signature
|
||||
*/
|
||||
function createSignature(data: string): string {
|
||||
return crypto
|
||||
.createHmac("sha256", getJwtSecret())
|
||||
.update(data)
|
||||
.digest("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JWT token
|
||||
*/
|
||||
export function createJwt(
|
||||
payload: Omit<JwtPayload, "iat" | "exp">,
|
||||
expiresIn: number,
|
||||
): string {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const fullPayload: JwtPayload = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + expiresIn,
|
||||
};
|
||||
|
||||
const headerEncoded = base64UrlEncode(JSON.stringify(header));
|
||||
const payloadEncoded = base64UrlEncode(JSON.stringify(fullPayload));
|
||||
const signature = createSignature(`${headerEncoded}.${payloadEncoded}`);
|
||||
|
||||
return `${headerEncoded}.${payloadEncoded}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode a JWT token
|
||||
* @returns The decoded payload or null if invalid
|
||||
*/
|
||||
export function verifyJwt(token: string): JwtPayload | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [headerEncoded, payloadEncoded, signature] = parts;
|
||||
const expectedSignature = createSignature(
|
||||
`${headerEncoded}.${payloadEncoded}`,
|
||||
);
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if (
|
||||
!crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload: JwtPayload = JSON.parse(base64UrlDecode(payloadEncoded));
|
||||
|
||||
// Check expiration
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < now) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TOKEN GENERATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a random token ID
|
||||
*/
|
||||
export function generateTokenId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a token using SHA-256
|
||||
*/
|
||||
@@ -151,74 +7,6 @@ export function hashToken(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display prefix of a token (first 8 chars after prefix)
|
||||
*/
|
||||
export function getTokenPrefix(token: string): string {
|
||||
// Remove "os_" prefix and get first 8 chars
|
||||
const withoutPrefix = token.replace(/^os_/, "");
|
||||
return `os_${withoutPrefix.substring(0, 8)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete token pair (access + refresh)
|
||||
*/
|
||||
export function generateTokenPair(
|
||||
userId: string,
|
||||
deviceId?: string,
|
||||
): TokenPair {
|
||||
const tokenId = generateTokenId();
|
||||
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
|
||||
|
||||
const accessToken = createJwt(
|
||||
{ sub: userId, type: "api_access", tokenId, deviceId },
|
||||
ACCESS_TOKEN_EXPIRY,
|
||||
);
|
||||
|
||||
const refreshToken = createJwt(
|
||||
{ sub: userId, type: "api_refresh", tokenId, deviceId },
|
||||
REFRESH_TOKEN_EXPIRY,
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenId,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token using a refresh token
|
||||
*/
|
||||
export function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
): { accessToken: string; expiresAt: Date } | null {
|
||||
const payload = verifyJwt(refreshToken);
|
||||
|
||||
if (!payload || payload.type !== "api_refresh") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
|
||||
|
||||
const accessToken = createJwt(
|
||||
{
|
||||
sub: payload.sub,
|
||||
type: "api_access",
|
||||
tokenId: payload.tokenId,
|
||||
deviceId: payload.deviceId,
|
||||
},
|
||||
ACCESS_TOKEN_EXPIRY,
|
||||
);
|
||||
|
||||
return { accessToken, expiresAt };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VALIDATION HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract bearer token from Authorization header
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user