diff --git a/.env.example b/.env.example index f5799af..83b7420 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,11 @@ SECRET= NODE_ENV=production PORT=3000 +# Cookie Security (Optional) +# Set to 'false' if accessing over HTTP (e.g., local LAN without HTTPS) +# When unset, auto-detects HTTPS via X-Forwarded-Proto header +# COOKIE_SECURE=false + # Timezone for logs (Optional) TZ=America/New_York diff --git a/README.md b/README.md index 09995a3..de86c5d 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,11 @@ PGID=1000 # Optional - JWT Secret (auto-generated if not provided) # Generate with: openssl rand -base64 32 SECRET= + +# Optional - Cookie Security +# Set to 'false' for HTTP access (e.g., local LAN without HTTPS) +# When unset, auto-detects HTTPS via X-Forwarded-Proto header +COOKIE_SECURE=false ``` ### User Permissions (PUID/PGID) diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 22c5289..f4b54c4 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { generateAccessToken, generateRefreshToken, validateAdminCredentials } from '@/lib/auth/utils'; +import { generateAccessToken, generateRefreshToken, validateAdminCredentials, isSecureCookie } from '@/lib/auth/utils'; export async function POST(request: NextRequest) { try { @@ -36,7 +36,7 @@ export async function POST(request: NextRequest) { // Set cookies const cookieOptions = { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: isSecureCookie(request), sameSite: 'lax' as const, path: '/', }; diff --git a/app/api/auth/refresh/route.ts b/app/api/auth/refresh/route.ts index 677053c..537461f 100644 --- a/app/api/auth/refresh/route.ts +++ b/app/api/auth/refresh/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { generateAccessToken, generateRefreshToken, verifyRefreshToken } from '@/lib/auth/utils'; +import { generateAccessToken, generateRefreshToken, verifyRefreshToken, isSecureCookie } from '@/lib/auth/utils'; export async function POST(request: NextRequest) { try { @@ -49,7 +49,7 @@ export async function POST(request: NextRequest) { // Set new cookies const cookieOptions = { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: isSecureCookie(request), sameSite: 'lax' as const, path: '/', }; diff --git a/app/api/lock/route.ts b/app/api/lock/route.ts index 3d653bb..7b592cd 100644 --- a/app/api/lock/route.ts +++ b/app/api/lock/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { eq } from 'drizzle-orm'; import { db, settings } from '@/lib/db'; import crypto from 'crypto'; +import { isSecureCookie } from '@/lib/auth/utils'; // POST /api/lock - Verify password export async function POST(request: NextRequest) { @@ -41,12 +42,11 @@ export async function POST(request: NextRequest) { message: 'Password verified', }); - // Set an unlock cookie that expires in 24 hours response.cookies.set('site_unlocked', 'true', { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: isSecureCookie(request), sameSite: 'lax', - maxAge: 60 * 60 * 24, // 24 hours + maxAge: 60 * 60 * 24, path: '/', }); diff --git a/lib/auth/utils.ts b/lib/auth/utils.ts index 83f1781..f69920e 100644 --- a/lib/auth/utils.ts +++ b/lib/auth/utils.ts @@ -128,3 +128,16 @@ export function validateAdminCredentials(username: string, password: string): bo return username === adminUsername && password === adminPassword; } + +export function isSecureCookie(request: { headers: { get(name: string): string | null }; url: string }): boolean { + if (process.env.COOKIE_SECURE !== undefined) { + return process.env.COOKIE_SECURE === 'true'; + } + if (process.env.NODE_ENV === 'production') { + return ( + request.headers.get('x-forwarded-proto') === 'https' || + request.url.startsWith('https://') + ); + } + return false; +}