From 8e2286d847d4ba0482cd8ff2619453cf7a2b36dd Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Fri, 7 Nov 2025 13:31:16 +0100 Subject: [PATCH] feat: Add persistent session authentication with configurable duration - Implement persistent session authentication with httpOnly cookies - Add configurable session duration (1-365 days) in settings - Add session expiration display in settings modal - Add logout button next to theme toggle - Enhance token verification to return expiration time - Add retry logic for failed auth checks - Add comprehensive authentication documentation to help modal - Improve session restoration on page load --- src/app/_components/AuthProvider.tsx | 48 ++++- src/app/_components/GeneralSettingsModal.tsx | 167 +++++++++++++++++- src/app/_components/HelpModal.tsx | 108 ++++++++++- src/app/api/auth/login/route.ts | 7 +- src/app/api/auth/verify/route.ts | 9 +- .../api/settings/auth-credentials/route.ts | 98 ++++++---- src/app/page.tsx | 18 +- src/lib/auth.ts | 60 ++++++- 8 files changed, 457 insertions(+), 58 deletions(-) diff --git a/src/app/_components/AuthProvider.tsx b/src/app/_components/AuthProvider.tsx index a9efd3f..1f7de94 100644 --- a/src/app/_components/AuthProvider.tsx +++ b/src/app/_components/AuthProvider.tsx @@ -1,11 +1,12 @@ 'use client'; -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react'; interface AuthContextType { isAuthenticated: boolean; username: string | null; isLoading: boolean; + expirationTime: number | null; login: (username: string, password: string) => Promise; logout: () => void; checkAuth: () => Promise; @@ -21,8 +22,9 @@ export function AuthProvider({ children }: AuthProviderProps) { const [isAuthenticated, setIsAuthenticated] = useState(false); const [username, setUsername] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [expirationTime, setExpirationTime] = useState(null); - const checkAuth = async () => { + const checkAuthInternal = async (retryCount = 0) => { try { // First check if setup is completed const setupResponse = await fetch('/api/settings/auth-credentials'); @@ -33,30 +35,60 @@ export function AuthProvider({ children }: AuthProviderProps) { if (!setupData.setupCompleted || !setupData.enabled) { setIsAuthenticated(false); setUsername(null); + setExpirationTime(null); setIsLoading(false); return; } } // Only verify authentication if setup is completed and auth is enabled - const response = await fetch('/api/auth/verify'); + const response = await fetch('/api/auth/verify', { + credentials: 'include', // Ensure cookies are sent + }); if (response.ok) { - const data = await response.json() as { username: string }; + const data = await response.json() as { + username: string; + expirationTime?: number | null; + timeUntilExpiration?: number | null; + }; setIsAuthenticated(true); setUsername(data.username); + setExpirationTime(data.expirationTime ?? null); } else { setIsAuthenticated(false); setUsername(null); + setExpirationTime(null); + + // Retry logic for failed auth checks (max 2 retries) + if (retryCount < 2) { + setTimeout(() => { + void checkAuthInternal(retryCount + 1); + }, 500); + return; + } } } catch (error) { console.error('Error checking auth:', error); setIsAuthenticated(false); setUsername(null); + setExpirationTime(null); + + // Retry logic for network errors (max 2 retries) + if (retryCount < 2) { + setTimeout(() => { + void checkAuthInternal(retryCount + 1); + }, 500); + return; + } } finally { setIsLoading(false); } }; + const checkAuth = useCallback(() => { + return checkAuthInternal(0); + }, []); + const login = async (username: string, password: string): Promise => { try { const response = await fetch('/api/auth/login', { @@ -65,12 +97,16 @@ export function AuthProvider({ children }: AuthProviderProps) { 'Content-Type': 'application/json', }, body: JSON.stringify({ username, password }), + credentials: 'include', // Ensure cookies are received }); if (response.ok) { const data = await response.json() as { username: string }; setIsAuthenticated(true); setUsername(data.username); + + // Check auth again to get expiration time + await checkAuth(); return true; } else { const errorData = await response.json(); @@ -88,11 +124,12 @@ export function AuthProvider({ children }: AuthProviderProps) { document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; setIsAuthenticated(false); setUsername(null); + setExpirationTime(null); }; useEffect(() => { void checkAuth(); - }, []); + }, [checkAuth]); return ( ('general'); + const [sessionExpirationDisplay, setSessionExpirationDisplay] = useState(''); const [githubToken, setGithubToken] = useState(''); const [saveFilter, setSaveFilter] = useState(false); const [savedFilters, setSavedFilters] = useState(null); @@ -34,6 +37,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr const [authHasCredentials, setAuthHasCredentials] = useState(false); const [authSetupCompleted, setAuthSetupCompleted] = useState(false); const [authLoading, setAuthLoading] = useState(false); + const [sessionDurationDays, setSessionDurationDays] = useState(7); // Auto-sync state const [autoSyncEnabled, setAutoSyncEnabled] = useState(false); @@ -214,11 +218,12 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr try { const response = await fetch('/api/settings/auth-credentials'); if (response.ok) { - const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean }; + const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean; sessionDurationDays?: number }; setAuthUsername(data.username ?? ''); setAuthEnabled(data.enabled ?? false); setAuthHasCredentials(data.hasCredentials ?? false); setAuthSetupCompleted(data.setupCompleted ?? false); + setSessionDurationDays(data.sessionDurationDays ?? 7); } } catch (error) { console.error('Error loading auth credentials:', error); @@ -227,6 +232,64 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr } }; + // Format expiration time display + const formatExpirationTime = (expTime: number | null): string => { + if (!expTime) return 'No active session'; + + const now = Date.now(); + const timeUntilExpiration = expTime - now; + + if (timeUntilExpiration <= 0) { + return 'Session expired'; + } + + const days = Math.floor(timeUntilExpiration / (1000 * 60 * 60 * 24)); + const hours = Math.floor((timeUntilExpiration % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((timeUntilExpiration % (1000 * 60 * 60)) / (1000 * 60)); + + const parts: string[] = []; + if (days > 0) { + parts.push(`${days} ${days === 1 ? 'day' : 'days'}`); + } + if (hours > 0) { + parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`); + } + if (minutes > 0 && days === 0) { + parts.push(`${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`); + } + + if (parts.length === 0) { + return 'Less than a minute'; + } + + return parts.join(', '); + }; + + // Update expiration display periodically + useEffect(() => { + const updateExpirationDisplay = () => { + if (expirationTime) { + setSessionExpirationDisplay(formatExpirationTime(expirationTime)); + } else { + setSessionExpirationDisplay(''); + } + }; + + updateExpirationDisplay(); + + // Update every minute + const interval = setInterval(updateExpirationDisplay, 60000); + + return () => clearInterval(interval); + }, [expirationTime]); + + // Refresh auth when tab changes to auth tab + useEffect(() => { + if (activeTab === 'auth' && isOpen) { + void checkAuth(); + } + }, [activeTab, isOpen, checkAuth]); + const saveAuthCredentials = async () => { if (authPassword !== authConfirmPassword) { setMessage({ type: 'error', text: 'Passwords do not match' }); @@ -265,6 +328,41 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr } }; + const saveSessionDuration = async (days: number) => { + if (days < 1 || days > 365) { + setMessage({ type: 'error', text: 'Session duration must be between 1 and 365 days' }); + return; + } + + setAuthLoading(true); + setMessage(null); + + try { + const response = await fetch('/api/settings/auth-credentials', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ sessionDurationDays: days }), + }); + + if (response.ok) { + setMessage({ type: 'success', text: `Session duration updated to ${days} days` }); + setSessionDurationDays(days); + setTimeout(() => setMessage(null), 3000); + } else { + const errorData = await response.json(); + setMessage({ type: 'error', text: errorData.error ?? 'Failed to update session duration' }); + setTimeout(() => setMessage(null), 3000); + } + } catch { + setMessage({ type: 'error', text: 'Failed to update session duration' }); + setTimeout(() => setMessage(null), 3000); + } finally { + setAuthLoading(false); + } + }; + const toggleAuthEnabled = async (enabled: boolean) => { setAuthLoading(true); setMessage(null); @@ -662,7 +760,10 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr {activeTab === 'auth' && (
-

Authentication Settings

+
+

Authentication Settings

+ +

Configure authentication to secure access to your application.

@@ -699,6 +800,68 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
+ {isAuthenticated && expirationTime && ( +
+

Session Information

+
+
+

Session expires in:

+

{sessionExpirationDisplay}

+
+
+

Expiration date:

+

+ {new Date(expirationTime).toLocaleString()} +

+
+
+
+ )} + +
+

Session Duration

+

+ Configure how long user sessions should last before requiring re-authentication. +

+ +
+
+ +
+ ) => { + const value = parseInt(e.target.value, 10); + if (!isNaN(value)) { + setSessionDurationDays(value); + } + }} + disabled={authLoading || !authSetupCompleted} + className="w-32" + /> + days (1-365) + +
+

+ Note: This setting applies to new logins. Current sessions will not be affected. +

+
+
+
+

Update Credentials

diff --git a/src/app/_components/HelpModal.tsx b/src/app/_components/HelpModal.tsx index 66608f0..df65672 100644 --- a/src/app/_components/HelpModal.tsx +++ b/src/app/_components/HelpModal.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Button } from './ui/button'; -import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download } from 'lucide-react'; +import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download, Lock } from 'lucide-react'; import { useRegisterModal } from './modal/ModalStackProvider'; interface HelpModalProps { @@ -11,7 +11,7 @@ interface HelpModalProps { initialSection?: string; } -type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system'; +type HelpSection = 'server-settings' | 'general-settings' | 'auth-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system'; export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) { useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose }); @@ -22,6 +22,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' const sections = [ { id: 'server-settings' as HelpSection, label: 'Server Settings', icon: Server }, { id: 'general-settings' as HelpSection, label: 'General Settings', icon: Settings }, + { id: 'auth-settings' as HelpSection, label: 'Authentication Settings', icon: Lock }, { id: 'sync-button' as HelpSection, label: 'Sync Button', icon: RefreshCw }, { id: 'auto-sync' as HelpSection, label: 'Auto-Sync', icon: Clock }, { id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package }, @@ -126,16 +127,113 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'

  • • Token is stored securely and only used for API calls
  • + + + ); + case 'auth-settings': + return ( +
    +
    +

    Authentication Settings

    +

    + Secure your application with username and password authentication and configure session management. +

    +
    + +
    -

    Authentication

    +

    Overview

    - Secure your application with username and password authentication. + Authentication settings allow you to secure access to your application with username and password protection. + Sessions persist across page refreshes, so users don't need to log in repeatedly.

    • • Set up username and password for app access
    • • Enable/disable authentication as needed
    • -
    • • Credentials are stored securely
    • +
    • • Credentials are stored securely using bcrypt hashing
    • +
    • • Sessions use secure httpOnly cookies
    • +
    +
    + +
    +

    Setting Up Authentication

    +
      +
    1. Navigate to General Settings → Authentication tab
    2. +
    3. Enter a username (minimum 3 characters)
    4. +
    5. Enter a password (minimum 6 characters)
    6. +
    7. Confirm your password
    8. +
    9. Click "Save Credentials" to save your authentication settings
    10. +
    11. Toggle "Enable Authentication" to activate authentication
    12. +
    +
    + +
    +

    Session Duration

    +

    + Configure how long user sessions should last before requiring re-authentication. +

    +
      +
    • Configurable Duration: Set session duration from 1 to 365 days
    • +
    • Default Duration: Sessions default to 7 days if not configured
    • +
    • Session Persistence: Sessions persist across page refreshes and browser restarts
    • +
    • New Logins Only: Duration changes apply to new logins, not existing sessions
    • +
    +
    +
    How to Configure:
    +
      +
    1. Go to General Settings → Authentication tab
    2. +
    3. Find the "Session Duration" section
    4. +
    5. Enter the number of days (1-365)
    6. +
    7. Click "Save" to apply the setting
    8. +
    +
    +
    + +
    +

    Session Information

    +

    + When authenticated, you can view your current session information in the Authentication tab. +

    +
      +
    • Time Until Expiration: See how much time remains before your session expires
    • +
    • Expiration Date: View the exact date and time your session will expire
    • +
    • Auto-Update: The expiration display updates every minute
    • +
    +
    + +
    +

    Updating Credentials

    +

    + You can change your username and password at any time from the Authentication tab. +

    +
      +
    • • Update username without changing password (leave password fields empty)
    • +
    • • Change password by entering a new password and confirmation
    • +
    • • Both username and password can be updated together
    • +
    • • Changes take effect immediately after saving
    • +
    +
    + +
    +

    Security Features

    +
      +
    • Password Hashing: Passwords are hashed using bcrypt before storage
    • +
    • Secure Cookies: Authentication tokens stored in httpOnly cookies
    • +
    • HTTPS in Production: Cookies are secure (HTTPS-only) in production mode
    • +
    • SameSite Protection: Cookies use strict SameSite policy to prevent CSRF attacks
    • +
    • JWT Tokens: Sessions use JSON Web Tokens with expiration
    • +
    +
    + +
    +

    ⚠️ Important Notes

    +
      +
    • First-Time Setup: You must complete the initial setup before enabling authentication
    • +
    • Session Duration: Changes to session duration only affect new logins
    • +
    • Logout: You can log out manually, which immediately invalidates your session
    • +
    • Lost Credentials: If you forget your password, you'll need to reset it manually in the .env file
    • +
    • Disabling Auth: Disabling authentication clears all credentials and allows unrestricted access
    diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index b562b1f..99d2570 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -38,7 +38,8 @@ export async function POST(request: NextRequest) { ); } - const token = generateToken(username); + const sessionDurationDays = authConfig.sessionDurationDays; + const token = generateToken(username, sessionDurationDays); const response = NextResponse.json({ success: true, @@ -46,12 +47,12 @@ export async function POST(request: NextRequest) { username }); - // Set httpOnly cookie + // Set httpOnly cookie with configured duration response.cookies.set('auth-token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', - maxAge: 7 * 24 * 60 * 60, // 7 days + maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration path: '/', }); diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts index b111154..fd912d8 100644 --- a/src/app/api/auth/verify/route.ts +++ b/src/app/api/auth/verify/route.ts @@ -22,10 +22,17 @@ export async function GET(request: NextRequest) { ); } + // Calculate expiration time in milliseconds + const expirationTime = decoded.exp ? decoded.exp * 1000 : null; + const currentTime = Date.now(); + const timeUntilExpiration = expirationTime ? expirationTime - currentTime : null; + return NextResponse.json({ success: true, username: decoded.username, - authenticated: true + authenticated: true, + expirationTime, + timeUntilExpiration }); } catch (error) { console.error('Error verifying token:', error); diff --git a/src/app/api/settings/auth-credentials/route.ts b/src/app/api/settings/auth-credentials/route.ts index dbc6836..afbe5e6 100644 --- a/src/app/api/settings/auth-credentials/route.ts +++ b/src/app/api/settings/auth-credentials/route.ts @@ -1,6 +1,6 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; -import { getAuthConfig, updateAuthCredentials, updateAuthEnabled } from '~/lib/auth'; +import { getAuthConfig, updateAuthCredentials, updateAuthEnabled, updateSessionDuration } from '~/lib/auth'; import fs from 'fs'; import path from 'path'; import { withApiLogging } from '../../../../server/logging/withApiLogging'; @@ -14,6 +14,7 @@ export const GET = withApiLogging(async function GET() { enabled: authConfig.enabled, hasCredentials: authConfig.hasCredentials, setupCompleted: authConfig.setupCompleted, + sessionDurationDays: authConfig.sessionDurationDays, }); } catch { // Error handled by withApiLogging @@ -66,48 +67,75 @@ export const POST = withApiLogging(async function POST(request: NextRequest) { export const PATCH = withApiLogging(async function PATCH(request: NextRequest) { try { - const { enabled } = await request.json() as { enabled: boolean }; + const body = await request.json() as { enabled?: boolean; sessionDurationDays?: number }; - if (typeof enabled !== 'boolean') { - return NextResponse.json( - { error: 'Enabled flag must be a boolean' }, - { status: 400 } - ); - } - - if (enabled) { - // When enabling, just update the flag - updateAuthEnabled(enabled); - } else { - // When disabling, clear all credentials and set flag to false - const envPath = path.join(process.cwd(), '.env'); - let envContent = ''; - if (fs.existsSync(envPath)) { - envContent = fs.readFileSync(envPath, 'utf8'); + if (body.enabled !== undefined) { + const { enabled } = body; + + if (typeof enabled !== 'boolean') { + return NextResponse.json( + { error: 'Enabled flag must be a boolean' }, + { status: 400 } + ); } - // Remove AUTH_USERNAME and AUTH_PASSWORD_HASH - envContent = envContent.replace(/^AUTH_USERNAME=.*$/m, ''); - envContent = envContent.replace(/^AUTH_PASSWORD_HASH=.*$/m, ''); - - // Update or add AUTH_ENABLED - const enabledRegex = /^AUTH_ENABLED=.*$/m; - if (enabledRegex.test(envContent)) { - envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false'); + if (enabled) { + // When enabling, just update the flag + updateAuthEnabled(enabled); } else { - envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n'; + // When disabling, clear all credentials and set flag to false + const envPath = path.join(process.cwd(), '.env'); + let envContent = ''; + if (fs.existsSync(envPath)) { + envContent = fs.readFileSync(envPath, 'utf8'); + } + + // Remove AUTH_USERNAME and AUTH_PASSWORD_HASH + envContent = envContent.replace(/^AUTH_USERNAME=.*$/m, ''); + envContent = envContent.replace(/^AUTH_PASSWORD_HASH=.*$/m, ''); + + // Update or add AUTH_ENABLED + const enabledRegex = /^AUTH_ENABLED=.*$/m; + if (enabledRegex.test(envContent)) { + envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false'); + } else { + envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n'; + } + + // Clean up empty lines + envContent = envContent.replace(/\n\n+/g, '\n'); + + fs.writeFileSync(envPath, envContent); } - // Clean up empty lines - envContent = envContent.replace(/\n\n+/g, '\n'); - - fs.writeFileSync(envPath, envContent); + return NextResponse.json({ + success: true, + message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully` + }); } - return NextResponse.json({ - success: true, - message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully` - }); + if (body.sessionDurationDays !== undefined) { + const { sessionDurationDays } = body; + + if (typeof sessionDurationDays !== 'number' || sessionDurationDays < 1 || sessionDurationDays > 365) { + return NextResponse.json( + { error: 'Session duration must be a number between 1 and 365 days' }, + { status: 400 } + ); + } + + updateSessionDuration(sessionDurationDays); + + return NextResponse.json({ + success: true, + message: `Session duration updated to ${sessionDurationDays} days` + }); + } + + return NextResponse.json( + { error: 'No valid field to update' }, + { status: 400 } + ); } catch { // Error handled by withApiLogging return NextResponse.json( diff --git a/src/app/page.tsx b/src/app/page.tsx index 2c43788..8e13279 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -16,10 +16,12 @@ import { Button } from './_components/ui/button'; import { ContextualHelpIcon } from './_components/ContextualHelpIcon'; import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal'; import { Footer } from './_components/Footer'; -import { Package, HardDrive, FolderOpen } from 'lucide-react'; +import { Package, HardDrive, FolderOpen, LogOut } from 'lucide-react'; import { api } from '~/trpc/react'; +import { useAuth } from './_components/AuthProvider'; export default function Home() { + const { isAuthenticated, logout } = useAuth(); const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null); const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => { if (typeof window !== 'undefined') { @@ -152,7 +154,19 @@ export default function Home() {

    PVE Scripts Management

    -
    +
    + {isAuthenticated && ( + + )}
    diff --git a/src/lib/auth.ts b/src/lib/auth.ts index f4c7d3c..809ea9f 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -5,7 +5,7 @@ import fs from 'fs'; import path from 'path'; const SALT_ROUNDS = 10; -const JWT_EXPIRY = '7d'; // 7 days +const DEFAULT_JWT_EXPIRY_DAYS = 7; // Default 7 days // Cache for JWT secret to avoid multiple file reads let jwtSecretCache: string | null = null; @@ -66,18 +66,31 @@ export async function comparePassword(password: string, hash: string): Promise