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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ PORT=3000
NEXTAUTH_SECRET=your-secret-key-here
NEXTAUTH_URL=http://localhost:3000

# Auth cookie "Secure" flag
# By default the Secure flag follows the request protocol: Secure over HTTPS
# (including behind a TLS-terminating proxy via x-forwarded-proto), and off over
# plain HTTP so login works on internal IP:port access. Override only if needed:
# FORCE_HTTPS=true -> always set Secure (deployment always behind HTTPS)
# DISABLE_SECURE_COOKIE=true -> never set Secure (explicit escape hatch)
# FORCE_HTTPS=false
# DISABLE_SECURE_COOKIE=false

# Optional: Analytics
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_ANALYTICS_ID=
Expand Down
97 changes: 97 additions & 0 deletions __tests__/auth-cookie.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { NextRequest } from 'next/server';
import {
isSecureRequest,
shouldUseSecureCookie,
authCookieOptions,
} from '@/lib/auth-cookie';

// Minimal NextRequest stub — the helper only reads headers.get() and nextUrl.protocol.
function makeRequest(opts: {
forwardedProto?: string;
protocol?: string;
}): NextRequest {
const { forwardedProto, protocol = 'http:' } = opts;
return {
headers: {
get: (name: string) =>
name.toLowerCase() === 'x-forwarded-proto'
? forwardedProto ?? null
: null,
},
nextUrl: { protocol },
} as unknown as NextRequest;
}

describe('isSecureRequest', () => {
it('is true when x-forwarded-proto is https', () => {
expect(isSecureRequest(makeRequest({ forwardedProto: 'https' }))).toBe(true);
});

it('uses the first value of a comma-separated x-forwarded-proto list', () => {
expect(
isSecureRequest(makeRequest({ forwardedProto: 'https,http' }))
).toBe(true);
expect(
isSecureRequest(makeRequest({ forwardedProto: 'http,https' }))
).toBe(false);
});

it('is false when x-forwarded-proto is http', () => {
expect(isSecureRequest(makeRequest({ forwardedProto: 'http' }))).toBe(false);
});

it('falls back to nextUrl.protocol when no forwarded header', () => {
expect(isSecureRequest(makeRequest({ protocol: 'https:' }))).toBe(true);
expect(isSecureRequest(makeRequest({ protocol: 'http:' }))).toBe(false);
});
});

describe('shouldUseSecureCookie', () => {
const OLD_ENV = process.env;
beforeEach(() => {
process.env = { ...OLD_ENV };
delete process.env.FORCE_HTTPS;
delete process.env.DISABLE_SECURE_COOKIE;
});
afterAll(() => {
process.env = OLD_ENV;
});

it('follows the request protocol by default', () => {
expect(shouldUseSecureCookie(makeRequest({ forwardedProto: 'https' }))).toBe(
true
);
expect(shouldUseSecureCookie(makeRequest({ protocol: 'http:' }))).toBe(false);
});

it('FORCE_HTTPS=true always returns true, even over plain HTTP', () => {
process.env.FORCE_HTTPS = 'true';
expect(shouldUseSecureCookie(makeRequest({ protocol: 'http:' }))).toBe(true);
});

it('DISABLE_SECURE_COOKIE=true always returns false, even over HTTPS', () => {
process.env.DISABLE_SECURE_COOKIE = 'true';
expect(
shouldUseSecureCookie(makeRequest({ forwardedProto: 'https' }))
).toBe(false);
});

it('FORCE_HTTPS takes precedence over DISABLE_SECURE_COOKIE', () => {
process.env.FORCE_HTTPS = 'true';
process.env.DISABLE_SECURE_COOKIE = 'true';
expect(shouldUseSecureCookie(makeRequest({ protocol: 'http:' }))).toBe(true);
});
});

describe('authCookieOptions', () => {
it('returns httpOnly/lax cookie options with protocol-derived secure flag', () => {
const opts = authCookieOptions(makeRequest({ protocol: 'http:' }), 3600);
expect(opts).toEqual({
httpOnly: true,
secure: false,
sameSite: 'lax',
maxAge: 3600,
path: '/',
});
});
});
13 changes: 6 additions & 7 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import fs from 'fs';
import path from 'path';
import { AUTH_COOKIE_NAME, authCookieOptions } from '@/lib/auth-cookie';

const AUTH_FILE = path.join(process.cwd(), 'data', 'auth.json');
const JWT_SECRET = process.env.JWT_SECRET || 'redis-explorer-secret-key';
Expand Down Expand Up @@ -64,13 +65,11 @@ export async function POST(request: NextRequest) {
message: 'Login successful'
});

response.cookies.set('auth-token', token, {
httpOnly: true,
secure: process.env.FORCE_HTTPS === 'true' || (process.env.NODE_ENV === 'production' && process.env.DISABLE_SECURE_COOKIE !== 'true'),
sameSite: 'lax', // Mudando para 'lax' para funcionar melhor em desenvolvimento
maxAge: 24 * 60 * 60, // 24 horas
path: '/', // Garantindo que o cookie seja válido para todo o site
});
response.cookies.set(
AUTH_COOKIE_NAME,
token,
authCookieOptions(request, 24 * 60 * 60) // 24 horas
);

console.log('[Auth Login] Cookie set successfully');

Expand Down
27 changes: 13 additions & 14 deletions src/app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { AUTH_COOKIE_NAME, authCookieOptions } from '@/lib/auth-cookie';

// POST - Faz logout removendo o cookie
export async function POST() {
export async function POST(request: NextRequest) {
try {
console.log('🚪 API /auth/logout chamada');

const response = NextResponse.json({
success: true,
message: 'Logout successful'
});

// Remove o cookie de autenticação
response.cookies.set('auth-token', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 0, // Expira imediatamente
path: '/',
const response = NextResponse.json({
success: true,
message: 'Logout successful'
});

// Remove o cookie de autenticação (mesmas flags do set, maxAge 0)
response.cookies.set(
AUTH_COOKIE_NAME,
'',
authCookieOptions(request, 0) // Expira imediatamente
);

console.log('✅ Cookie auth-token removido com sucesso');
return response;
} catch (error) {
Expand Down
13 changes: 6 additions & 7 deletions src/app/api/auth/setup/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import fs from 'fs';
import path from 'path';
import { AUTH_COOKIE_NAME, authCookieOptions } from '@/lib/auth-cookie';

const AUTH_FILE = path.join(process.cwd(), 'data', 'auth.json');
const JWT_SECRET = process.env.JWT_SECRET || 'redis-explorer-secret-key';
Expand Down Expand Up @@ -86,13 +87,11 @@ export async function POST(request: NextRequest) {
message: 'Password configured successfully'
});

response.cookies.set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60, // 24 horas
path: '/',
});
response.cookies.set(
AUTH_COOKIE_NAME,
token,
authCookieOptions(request, 24 * 60 * 60) // 24 horas
);

return response;
} catch (error) {
Expand Down
55 changes: 55 additions & 0 deletions src/lib/auth-cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { NextRequest } from 'next/server';

export const AUTH_COOKIE_NAME = 'auth-token';
export const SESSION_COOKIE_NAME = 'redis-explorer-session';

/**
* Detecta se a requisição chegou via HTTPS.
*
* Considera o header `x-forwarded-proto` (definido por proxies reversos que
* terminam o TLS, ex: nginx/Traefik) antes de cair no protocolo da própria
* requisição — necessário porque, atrás de um proxy, a conexão interna é HTTP.
*/
export function isSecureRequest(request: NextRequest): boolean {
const forwardedProto = request.headers.get('x-forwarded-proto');
if (forwardedProto) {
// Pode vir como lista: "https,http" — o primeiro valor é o do cliente.
return forwardedProto.split(',')[0].trim().toLowerCase() === 'https';
}
return request.nextUrl.protocol === 'https:';
}

/**
* Decide se o cookie de autenticação deve receber a flag `Secure`.
*
* - `FORCE_HTTPS=true` → sempre Secure (deploy sempre atrás de HTTPS).
* - `DISABLE_SECURE_COOKIE=true` → nunca Secure (escape hatch explícito).
* - caso contrário → segue o protocolo real da requisição, de modo
* que o login funcione sobre HTTP simples (ex.: acesso interno por IP:porta)
* sem abrir mão do Secure quando servido sobre HTTPS.
*
* Navegadores descartam cookies `Secure` em conexões HTTP (exceto localhost),
* então marcar Secure incondicionalmente quebra o login sobre HTTP simples.
*/
export function shouldUseSecureCookie(request: NextRequest): boolean {
if (process.env.FORCE_HTTPS === 'true') {
return true;
}
if (process.env.DISABLE_SECURE_COOKIE === 'true') {
return false;
}
return isSecureRequest(request);
}

/**
* Opções padrão do cookie de autenticação (`auth-token`).
*/
export function authCookieOptions(request: NextRequest, maxAge: number) {
return {
httpOnly: true,
secure: shouldUseSecureCookie(request),
sameSite: 'lax' as const,
maxAge,
path: '/',
};
}
11 changes: 6 additions & 5 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
import { SESSION_COOKIE_NAME, shouldUseSecureCookie } from '@/lib/auth-cookie';

export function middleware(request: NextRequest) {
const response = NextResponse.next();

// Criar sessionId único para cada usuário se não existir
if (!request.cookies.get('redis-explorer-session')) {
if (!request.cookies.get(SESSION_COOKIE_NAME)) {
const sessionId = uuidv4();
response.cookies.set('redis-explorer-session', sessionId, {
response.cookies.set(SESSION_COOKIE_NAME, sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
secure: shouldUseSecureCookie(request),
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 dias
});
}

return response;
}

Expand Down
Loading