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
This commit is contained in:
Michel Roegl-Brunner
2025-11-07 13:31:16 +01:00
parent 73776ec6ac
commit 8e2286d847
8 changed files with 457 additions and 58 deletions

View File

@@ -1,11 +1,12 @@
'use client'; 'use client';
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
interface AuthContextType { interface AuthContextType {
isAuthenticated: boolean; isAuthenticated: boolean;
username: string | null; username: string | null;
isLoading: boolean; isLoading: boolean;
expirationTime: number | null;
login: (username: string, password: string) => Promise<boolean>; login: (username: string, password: string) => Promise<boolean>;
logout: () => void; logout: () => void;
checkAuth: () => Promise<void>; checkAuth: () => Promise<void>;
@@ -21,8 +22,9 @@ export function AuthProvider({ children }: AuthProviderProps) {
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState<string | null>(null); const [username, setUsername] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [expirationTime, setExpirationTime] = useState<number | null>(null);
const checkAuth = async () => { const checkAuthInternal = async (retryCount = 0) => {
try { try {
// First check if setup is completed // First check if setup is completed
const setupResponse = await fetch('/api/settings/auth-credentials'); const setupResponse = await fetch('/api/settings/auth-credentials');
@@ -33,30 +35,60 @@ export function AuthProvider({ children }: AuthProviderProps) {
if (!setupData.setupCompleted || !setupData.enabled) { if (!setupData.setupCompleted || !setupData.enabled) {
setIsAuthenticated(false); setIsAuthenticated(false);
setUsername(null); setUsername(null);
setExpirationTime(null);
setIsLoading(false); setIsLoading(false);
return; return;
} }
} }
// Only verify authentication if setup is completed and auth is enabled // 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) { 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); setIsAuthenticated(true);
setUsername(data.username); setUsername(data.username);
setExpirationTime(data.expirationTime ?? null);
} else { } else {
setIsAuthenticated(false); setIsAuthenticated(false);
setUsername(null); 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) { } catch (error) {
console.error('Error checking auth:', error); console.error('Error checking auth:', error);
setIsAuthenticated(false); setIsAuthenticated(false);
setUsername(null); setUsername(null);
setExpirationTime(null);
// Retry logic for network errors (max 2 retries)
if (retryCount < 2) {
setTimeout(() => {
void checkAuthInternal(retryCount + 1);
}, 500);
return;
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const checkAuth = useCallback(() => {
return checkAuthInternal(0);
}, []);
const login = async (username: string, password: string): Promise<boolean> => { const login = async (username: string, password: string): Promise<boolean> => {
try { try {
const response = await fetch('/api/auth/login', { const response = await fetch('/api/auth/login', {
@@ -65,12 +97,16 @@ export function AuthProvider({ children }: AuthProviderProps) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
credentials: 'include', // Ensure cookies are received
}); });
if (response.ok) { if (response.ok) {
const data = await response.json() as { username: string }; const data = await response.json() as { username: string };
setIsAuthenticated(true); setIsAuthenticated(true);
setUsername(data.username); setUsername(data.username);
// Check auth again to get expiration time
await checkAuth();
return true; return true;
} else { } else {
const errorData = await response.json(); 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=/;'; document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
setIsAuthenticated(false); setIsAuthenticated(false);
setUsername(null); setUsername(null);
setExpirationTime(null);
}; };
useEffect(() => { useEffect(() => {
void checkAuth(); void checkAuth();
}, []); }, [checkAuth]);
return ( return (
<AuthContext.Provider <AuthContext.Provider
@@ -100,6 +137,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
isAuthenticated, isAuthenticated,
username, username,
isLoading, isLoading,
expirationTime,
login, login,
logout, logout,
checkAuth, checkAuth,

View File

@@ -8,6 +8,7 @@ import { ContextualHelpIcon } from './ContextualHelpIcon';
import { useTheme } from './ThemeProvider'; import { useTheme } from './ThemeProvider';
import { useRegisterModal } from './modal/ModalStackProvider'; import { useRegisterModal } from './modal/ModalStackProvider';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { useAuth } from './AuthProvider';
interface GeneralSettingsModalProps { interface GeneralSettingsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -17,7 +18,9 @@ interface GeneralSettingsModalProps {
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) { export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose }); useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose });
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { isAuthenticated, expirationTime, checkAuth } = useAuth();
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth' | 'auto-sync'>('general'); const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth' | 'auto-sync'>('general');
const [sessionExpirationDisplay, setSessionExpirationDisplay] = useState<string>('');
const [githubToken, setGithubToken] = useState(''); const [githubToken, setGithubToken] = useState('');
const [saveFilter, setSaveFilter] = useState(false); const [saveFilter, setSaveFilter] = useState(false);
const [savedFilters, setSavedFilters] = useState<any>(null); const [savedFilters, setSavedFilters] = useState<any>(null);
@@ -34,6 +37,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const [authHasCredentials, setAuthHasCredentials] = useState(false); const [authHasCredentials, setAuthHasCredentials] = useState(false);
const [authSetupCompleted, setAuthSetupCompleted] = useState(false); const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
const [authLoading, setAuthLoading] = useState(false); const [authLoading, setAuthLoading] = useState(false);
const [sessionDurationDays, setSessionDurationDays] = useState(7);
// Auto-sync state // Auto-sync state
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false); const [autoSyncEnabled, setAutoSyncEnabled] = useState(false);
@@ -214,11 +218,12 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
try { try {
const response = await fetch('/api/settings/auth-credentials'); const response = await fetch('/api/settings/auth-credentials');
if (response.ok) { 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 ?? ''); setAuthUsername(data.username ?? '');
setAuthEnabled(data.enabled ?? false); setAuthEnabled(data.enabled ?? false);
setAuthHasCredentials(data.hasCredentials ?? false); setAuthHasCredentials(data.hasCredentials ?? false);
setAuthSetupCompleted(data.setupCompleted ?? false); setAuthSetupCompleted(data.setupCompleted ?? false);
setSessionDurationDays(data.sessionDurationDays ?? 7);
} }
} catch (error) { } catch (error) {
console.error('Error loading auth credentials:', 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 () => { const saveAuthCredentials = async () => {
if (authPassword !== authConfirmPassword) { if (authPassword !== authConfirmPassword) {
setMessage({ type: 'error', text: 'Passwords do not match' }); 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) => { const toggleAuthEnabled = async (enabled: boolean) => {
setAuthLoading(true); setAuthLoading(true);
setMessage(null); setMessage(null);
@@ -662,7 +760,10 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
{activeTab === 'auth' && ( {activeTab === 'auth' && (
<div className="space-y-4 sm:space-y-6"> <div className="space-y-4 sm:space-y-6">
<div> <div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Authentication Settings</h3> <div className="flex items-center gap-2 mb-3 sm:mb-4">
<h3 className="text-base sm:text-lg font-medium text-foreground">Authentication Settings</h3>
<ContextualHelpIcon section="auth-settings" tooltip="Help with Authentication Settings" />
</div>
<p className="text-sm sm:text-base text-muted-foreground mb-4"> <p className="text-sm sm:text-base text-muted-foreground mb-4">
Configure authentication to secure access to your application. Configure authentication to secure access to your application.
</p> </p>
@@ -699,6 +800,68 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div> </div>
</div> </div>
{isAuthenticated && expirationTime && (
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Session Information</h4>
<div className="space-y-2">
<div>
<p className="text-sm text-muted-foreground">Session expires in:</p>
<p className="text-sm font-medium text-foreground">{sessionExpirationDisplay}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Expiration date:</p>
<p className="text-sm font-medium text-foreground">
{new Date(expirationTime).toLocaleString()}
</p>
</div>
</div>
</div>
)}
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Session Duration</h4>
<p className="text-sm text-muted-foreground mb-4">
Configure how long user sessions should last before requiring re-authentication.
</p>
<div className="space-y-3">
<div>
<label htmlFor="session-duration" className="block text-sm font-medium text-foreground mb-1">
Session Duration (days)
</label>
<div className="flex items-center gap-3">
<Input
id="session-duration"
type="number"
min="1"
max="365"
placeholder="Enter days"
value={sessionDurationDays}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value)) {
setSessionDurationDays(value);
}
}}
disabled={authLoading || !authSetupCompleted}
className="w-32"
/>
<span className="text-sm text-muted-foreground">days (1-365)</span>
<Button
onClick={() => saveSessionDuration(sessionDurationDays)}
disabled={authLoading || !authSetupCompleted}
size="sm"
>
Save
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Note: This setting applies to new logins. Current sessions will not be affected.
</p>
</div>
</div>
</div>
<div className="p-4 border border-border rounded-lg"> <div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Update Credentials</h4> <h4 className="font-medium text-foreground mb-2">Update Credentials</h4>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from './ui/button'; 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'; import { useRegisterModal } from './modal/ModalStackProvider';
interface HelpModalProps { interface HelpModalProps {
@@ -11,7 +11,7 @@ interface HelpModalProps {
initialSection?: string; 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) { export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose }); useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose });
@@ -22,6 +22,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
const sections = [ const sections = [
{ id: 'server-settings' as HelpSection, label: 'Server Settings', icon: Server }, { id: 'server-settings' as HelpSection, label: 'Server Settings', icon: Server },
{ id: 'general-settings' as HelpSection, label: 'General Settings', icon: Settings }, { 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: 'sync-button' as HelpSection, label: 'Sync Button', icon: RefreshCw },
{ id: 'auto-sync' as HelpSection, label: 'Auto-Sync', icon: Clock }, { id: 'auto-sync' as HelpSection, label: 'Auto-Sync', icon: Clock },
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package }, { id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
@@ -126,16 +127,113 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
<li> Token is stored securely and only used for API calls</li> <li> Token is stored securely and only used for API calls</li>
</ul> </ul>
</div> </div>
</div>
</div>
);
case 'auth-settings':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">Authentication Settings</h3>
<p className="text-muted-foreground mb-6">
Secure your application with username and password authentication and configure session management.
</p>
</div>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg"> <div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Authentication</h4> <h4 className="font-medium text-foreground mb-2">Overview</h4>
<p className="text-sm text-muted-foreground mb-2"> <p className="text-sm text-muted-foreground mb-2">
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&apos;t need to log in repeatedly.
</p> </p>
<ul className="text-sm text-muted-foreground space-y-1"> <ul className="text-sm text-muted-foreground space-y-1">
<li> Set up username and password for app access</li> <li> Set up username and password for app access</li>
<li> Enable/disable authentication as needed</li> <li> Enable/disable authentication as needed</li>
<li> Credentials are stored securely</li> <li> Credentials are stored securely using bcrypt hashing</li>
<li> Sessions use secure httpOnly cookies</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Setting Up Authentication</h4>
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
<li>Navigate to General Settings Authentication tab</li>
<li>Enter a username (minimum 3 characters)</li>
<li>Enter a password (minimum 6 characters)</li>
<li>Confirm your password</li>
<li>Click &quot;Save Credentials&quot; to save your authentication settings</li>
<li>Toggle &quot;Enable Authentication&quot; to activate authentication</li>
</ol>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Session Duration</h4>
<p className="text-sm text-muted-foreground mb-2">
Configure how long user sessions should last before requiring re-authentication.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Configurable Duration:</strong> Set session duration from 1 to 365 days</li>
<li> <strong>Default Duration:</strong> Sessions default to 7 days if not configured</li>
<li> <strong>Session Persistence:</strong> Sessions persist across page refreshes and browser restarts</li>
<li> <strong>New Logins Only:</strong> Duration changes apply to new logins, not existing sessions</li>
</ul>
<div className="mt-3 p-3 bg-info/10 rounded-md">
<h5 className="font-medium text-info-foreground mb-2">How to Configure:</h5>
<ol className="text-xs text-info/80 space-y-1 list-decimal list-inside">
<li>Go to General Settings Authentication tab</li>
<li>Find the &quot;Session Duration&quot; section</li>
<li>Enter the number of days (1-365)</li>
<li>Click &quot;Save&quot; to apply the setting</li>
</ol>
</div>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Session Information</h4>
<p className="text-sm text-muted-foreground mb-2">
When authenticated, you can view your current session information in the Authentication tab.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>Time Until Expiration:</strong> See how much time remains before your session expires</li>
<li> <strong>Expiration Date:</strong> View the exact date and time your session will expire</li>
<li> <strong>Auto-Update:</strong> The expiration display updates every minute</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Updating Credentials</h4>
<p className="text-sm text-muted-foreground mb-2">
You can change your username and password at any time from the Authentication tab.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Update username without changing password (leave password fields empty)</li>
<li> Change password by entering a new password and confirmation</li>
<li> Both username and password can be updated together</li>
<li> Changes take effect immediately after saving</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg bg-muted/50">
<h4 className="font-medium text-foreground mb-2">Security Features</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Password Hashing:</strong> Passwords are hashed using bcrypt before storage</li>
<li> <strong>Secure Cookies:</strong> Authentication tokens stored in httpOnly cookies</li>
<li> <strong>HTTPS in Production:</strong> Cookies are secure (HTTPS-only) in production mode</li>
<li> <strong>SameSite Protection:</strong> Cookies use strict SameSite policy to prevent CSRF attacks</li>
<li> <strong>JWT Tokens:</strong> Sessions use JSON Web Tokens with expiration</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg bg-warning/10 border-warning/20">
<h4 className="font-medium text-warning-foreground mb-2"> Important Notes</h4>
<ul className="text-sm text-warning/80 space-y-2">
<li> <strong>First-Time Setup:</strong> You must complete the initial setup before enabling authentication</li>
<li> <strong>Session Duration:</strong> Changes to session duration only affect new logins</li>
<li> <strong>Logout:</strong> You can log out manually, which immediately invalidates your session</li>
<li> <strong>Lost Credentials:</strong> If you forget your password, you&apos;ll need to reset it manually in the .env file</li>
<li> <strong>Disabling Auth:</strong> Disabling authentication clears all credentials and allows unrestricted access</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -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({ const response = NextResponse.json({
success: true, success: true,
@@ -46,12 +47,12 @@ export async function POST(request: NextRequest) {
username username
}); });
// Set httpOnly cookie // Set httpOnly cookie with configured duration
response.cookies.set('auth-token', token, { response.cookies.set('auth-token', token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'strict', sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60, // 7 days maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration
path: '/', path: '/',
}); });

View File

@@ -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({ return NextResponse.json({
success: true, success: true,
username: decoded.username, username: decoded.username,
authenticated: true authenticated: true,
expirationTime,
timeUntilExpiration
}); });
} catch (error) { } catch (error) {
console.error('Error verifying token:', error); console.error('Error verifying token:', error);

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } 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 fs from 'fs';
import path from 'path'; import path from 'path';
import { withApiLogging } from '../../../../server/logging/withApiLogging'; import { withApiLogging } from '../../../../server/logging/withApiLogging';
@@ -14,6 +14,7 @@ export const GET = withApiLogging(async function GET() {
enabled: authConfig.enabled, enabled: authConfig.enabled,
hasCredentials: authConfig.hasCredentials, hasCredentials: authConfig.hasCredentials,
setupCompleted: authConfig.setupCompleted, setupCompleted: authConfig.setupCompleted,
sessionDurationDays: authConfig.sessionDurationDays,
}); });
} catch { } catch {
// Error handled by withApiLogging // 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) { export const PATCH = withApiLogging(async function PATCH(request: NextRequest) {
try { try {
const { enabled } = await request.json() as { enabled: boolean }; const body = await request.json() as { enabled?: boolean; sessionDurationDays?: number };
if (typeof enabled !== 'boolean') { if (body.enabled !== undefined) {
return NextResponse.json( const { enabled } = body;
{ error: 'Enabled flag must be a boolean' },
{ status: 400 } 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');
} }
// Remove AUTH_USERNAME and AUTH_PASSWORD_HASH if (enabled) {
envContent = envContent.replace(/^AUTH_USERNAME=.*$/m, ''); // When enabling, just update the flag
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=.*$/m, ''); updateAuthEnabled(enabled);
// Update or add AUTH_ENABLED
const enabledRegex = /^AUTH_ENABLED=.*$/m;
if (enabledRegex.test(envContent)) {
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
} else { } 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 return NextResponse.json({
envContent = envContent.replace(/\n\n+/g, '\n'); success: true,
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
fs.writeFileSync(envPath, envContent); });
} }
return NextResponse.json({ if (body.sessionDurationDays !== undefined) {
success: true, const { sessionDurationDays } = body;
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
}); 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 { } catch {
// Error handled by withApiLogging // Error handled by withApiLogging
return NextResponse.json( return NextResponse.json(

View File

@@ -16,10 +16,12 @@ import { Button } from './_components/ui/button';
import { ContextualHelpIcon } from './_components/ContextualHelpIcon'; import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal'; import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
import { Footer } from './_components/Footer'; 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 { api } from '~/trpc/react';
import { useAuth } from './_components/AuthProvider';
export default function Home() { export default function Home() {
const { isAuthenticated, logout } = useAuth();
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null); const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => { const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -152,7 +154,19 @@ export default function Home() {
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground flex items-center justify-center gap-2 sm:gap-3 flex-1"> <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground flex items-center justify-center gap-2 sm:gap-3 flex-1">
<span className="break-words">PVE Scripts Management</span> <span className="break-words">PVE Scripts Management</span>
</h1> </h1>
<div className="flex-1 flex justify-end"> <div className="flex-1 flex justify-end items-center gap-2">
{isAuthenticated && (
<Button
variant="ghost"
size="icon"
onClick={logout}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Logout"
title="Logout"
>
<LogOut className="h-4 w-4" />
</Button>
)}
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
const SALT_ROUNDS = 10; 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 // Cache for JWT secret to avoid multiple file reads
let jwtSecretCache: string | null = null; let jwtSecretCache: string | null = null;
@@ -66,18 +66,31 @@ export async function comparePassword(password: string, hash: string): Promise<b
/** /**
* Generate a JWT token * Generate a JWT token
*/ */
export function generateToken(username: string): string { export function generateToken(username: string, durationDays?: number): string {
const secret = getJwtSecret(); const secret = getJwtSecret();
return jwt.sign({ username }, secret, { expiresIn: JWT_EXPIRY }); const days = durationDays ?? DEFAULT_JWT_EXPIRY_DAYS;
return jwt.sign({ username }, secret, { expiresIn: `${days}d` });
}
/**
* Decode a JWT token without verification (for extracting expiration time)
*/
export function decodeToken(token: string): { username: string; exp?: number; iat?: number } | null {
try {
const decoded = jwt.decode(token) as { username: string; exp?: number; iat?: number } | null;
return decoded;
} catch {
return null;
}
} }
/** /**
* Verify a JWT token * Verify a JWT token
*/ */
export function verifyToken(token: string): { username: string } | null { export function verifyToken(token: string): { username: string; exp?: number; iat?: number } | null {
try { try {
const secret = getJwtSecret(); const secret = getJwtSecret();
const decoded = jwt.verify(token, secret) as { username: string }; const decoded = jwt.verify(token, secret) as { username: string; exp?: number; iat?: number };
return decoded; return decoded;
} catch { } catch {
return null; return null;
@@ -93,6 +106,7 @@ export function getAuthConfig(): {
enabled: boolean; enabled: boolean;
hasCredentials: boolean; hasCredentials: boolean;
setupCompleted: boolean; setupCompleted: boolean;
sessionDurationDays: number;
} { } {
const envPath = path.join(process.cwd(), '.env'); const envPath = path.join(process.cwd(), '.env');
@@ -103,6 +117,7 @@ export function getAuthConfig(): {
enabled: false, enabled: false,
hasCredentials: false, hasCredentials: false,
setupCompleted: false, setupCompleted: false,
sessionDurationDays: DEFAULT_JWT_EXPIRY_DAYS,
}; };
} }
@@ -128,6 +143,13 @@ export function getAuthConfig(): {
const setupCompletedMatch = setupCompletedRegex.exec(envContent); const setupCompletedMatch = setupCompletedRegex.exec(envContent);
const setupCompleted = setupCompletedMatch ? setupCompletedMatch[1]?.trim().toLowerCase() === 'true' : false; const setupCompleted = setupCompletedMatch ? setupCompletedMatch[1]?.trim().toLowerCase() === 'true' : false;
// Extract AUTH_SESSION_DURATION_DAYS
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m;
const sessionDurationMatch = sessionDurationRegex.exec(envContent);
const sessionDurationDays = sessionDurationMatch
? parseInt(sessionDurationMatch[1]?.trim() || String(DEFAULT_JWT_EXPIRY_DAYS), 10) || DEFAULT_JWT_EXPIRY_DAYS
: DEFAULT_JWT_EXPIRY_DAYS;
const hasCredentials = !!(username && passwordHash); const hasCredentials = !!(username && passwordHash);
return { return {
@@ -136,6 +158,7 @@ export function getAuthConfig(): {
enabled, enabled,
hasCredentials, hasCredentials,
setupCompleted, setupCompleted,
sessionDurationDays,
}; };
} }
@@ -238,3 +261,30 @@ export function updateAuthEnabled(enabled: boolean): void {
fs.writeFileSync(envPath, envContent); fs.writeFileSync(envPath, envContent);
} }
/**
* Update AUTH_SESSION_DURATION_DAYS in .env
*/
export function updateSessionDuration(days: number): void {
// Validate: between 1 and 365 days
const validDays = Math.max(1, Math.min(365, Math.floor(days)));
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Update or add AUTH_SESSION_DURATION_DAYS
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=.*$/m;
if (sessionDurationRegex.test(envContent)) {
envContent = envContent.replace(sessionDurationRegex, `AUTH_SESSION_DURATION_DAYS=${validDays}`);
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_SESSION_DURATION_DAYS=${validDays}\n`;
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
}