Files
ProxmoxVE-Local/src/lib/auth.ts
Michel Roegl-Brunner 6265ffeab5 feat: Implement comprehensive authentication system (#99)
* feat: implement JWT-based authentication system

- Add bcrypt password hashing and JWT token generation
- Create blocking auth modals for login and setup
- Add authentication management to General Settings
- Implement API routes for login, verify, setup, and credential management
- Add AuthProvider and AuthGuard components
- Support first-time setup and persistent authentication
- Store credentials securely in .env file

* feat: add option to skip enabling auth during setup

- Add toggle in SetupModal to choose whether to enable authentication immediately
- Users can set up credentials but keep authentication disabled initially
- Authentication can be enabled/disabled later through General Settings
- Maintains flexibility for users who want to configure auth gradually

* fix: allow proceeding without password when auth is disabled

- Make password fields optional when authentication is disabled in setup
- Update button validation to only require password when auth is enabled
- Modify API to handle optional password parameter
- Update hasCredentials logic to work with username-only setup
- Users can now complete setup with just username when auth is disabled
- Password can be added later when enabling authentication

* feat: don't store credentials when authentication is disabled

- When auth is disabled, no username or password is stored
- Setup modal only requires credentials when authentication is enabled
- Disabling authentication clears all stored credentials
- Users can skip authentication entirely without storing any data
- Clean separation between enabled/disabled authentication states

* feat: add setup completed flag to prevent modal on every load

- Add AUTH_SETUP_COMPLETED flag to track when user has completed setup
- Setup modal only appears when setupCompleted is false
- Both enabled and disabled auth setups mark setup as completed
- Clean .env file when authentication is disabled (no empty credential lines)
- Prevents setup modal from appearing on every page load after user decision

* fix: add missing Authentication tab button in settings modal

- Authentication tab button was missing from the tabs navigation
- Users couldn't access authentication settings
- Added Authentication tab button with proper styling and click handler
- Authentication settings are now accessible through the settings modal

* fix: properly load and display authentication settings

- Add setupCompleted state variable to track setup status
- Update loadAuthCredentials to include setupCompleted field
- Fix authentication status display logic to show correct state
- Show proper status when auth is disabled but setup is completed
- Enable toggle only when setup is completed (not just when credentials exist)
- Settings now correctly reflect the actual authentication state

* fix: handle empty FILTERS environment variable

- Add check for empty or invalid FILTERS JSON before parsing
- Prevents 'Unexpected end of JSON input' error when FILTERS is empty
- Return null filters instead of throwing parse error
- Clean up empty FILTERS line from .env file
- Fixes console error when loading settings modal

* fix: load authentication credentials when settings modal opens

- Add loadAuthCredentials() call to useEffect when modal opens
- Authentication settings were not loading because the function wasn't being called
- Now properly loads auth configuration when settings modal is opened
- Settings will display the correct authentication status and state

* fix: prevent multiple JWT secret generation with caching

- Add JWT secret caching to prevent race conditions
- Multiple API calls were generating duplicate JWT secrets
- Now caches secret after first generation/read
- Clean up duplicate JWT_SECRET lines from .env file
- Prevents .env file from being cluttered with multiple secrets

* feat: auto-login user after setup with authentication enabled

- When user sets up authentication with credentials, automatically log them in
- Prevents need to manually log in after setup completion
- Setup modal now calls login API after successful setup when auth is enabled
- AuthGuard no longer reloads page after setup, just refreshes config
- Seamless user experience from setup to authenticated state

* fix: resolve console errors and improve auth flow

- Fix 401 Unauthorized error by checking setup status before auth verification
- AuthProvider now checks if setup is completed before attempting to verify auth
- Prevents unnecessary auth verification calls when no credentials exist
- Add webpack polling configuration to fix WebSocket HMR issues
- Improves development experience when accessing from different IPs
- Eliminates console errors during initial setup flow

* fix: resolve build errors and linting issues

- Fix TypeScript ESLint error: use optional chain expression in auth.ts
- Fix React Hook warning: add missing 'isRunning' dependency to useEffect in Terminal.tsx
- Build now compiles successfully without any errors or warnings
- All linting rules are now satisfied
2025-10-10 12:45:45 +02:00

241 lines
6.6 KiB
TypeScript

import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { randomBytes } from 'crypto';
import fs from 'fs';
import path from 'path';
const SALT_ROUNDS = 10;
const JWT_EXPIRY = '7d'; // 7 days
// Cache for JWT secret to avoid multiple file reads
let jwtSecretCache: string | null = null;
/**
* Get or generate JWT secret
*/
export function getJwtSecret(): string {
// Return cached secret if available
if (jwtSecretCache) {
return jwtSecretCache;
}
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Check if JWT_SECRET already exists
const jwtSecretRegex = /^JWT_SECRET=(.*)$/m;
const jwtSecretMatch = jwtSecretRegex.exec(envContent);
if (jwtSecretMatch?.[1]?.trim()) {
jwtSecretCache = jwtSecretMatch[1].trim();
return jwtSecretCache;
}
// Generate new secret
const newSecret = randomBytes(64).toString('hex');
// Add to .env file
envContent += (envContent.endsWith('\n') ? '' : '\n') + `JWT_SECRET=${newSecret}\n`;
fs.writeFileSync(envPath, envContent);
// Cache the new secret
jwtSecretCache = newSecret;
return newSecret;
}
/**
* Hash a password using bcrypt
*/
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
/**
* Compare a password with a hash
*/
export async function comparePassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
/**
* Generate a JWT token
*/
export function generateToken(username: string): string {
const secret = getJwtSecret();
return jwt.sign({ username }, secret, { expiresIn: JWT_EXPIRY });
}
/**
* Verify a JWT token
*/
export function verifyToken(token: string): { username: string } | null {
try {
const secret = getJwtSecret();
const decoded = jwt.verify(token, secret) as { username: string };
return decoded;
} catch {
return null;
}
}
/**
* Read auth configuration from .env
*/
export function getAuthConfig(): {
username: string | null;
passwordHash: string | null;
enabled: boolean;
hasCredentials: boolean;
setupCompleted: boolean;
} {
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return {
username: null,
passwordHash: null,
enabled: false,
hasCredentials: false,
setupCompleted: false,
};
}
const envContent = fs.readFileSync(envPath, 'utf8');
// Extract AUTH_USERNAME
const usernameRegex = /^AUTH_USERNAME=(.*)$/m;
const usernameMatch = usernameRegex.exec(envContent);
const username = usernameMatch ? usernameMatch[1]?.trim() : null;
// Extract AUTH_PASSWORD_HASH
const passwordHashRegex = /^AUTH_PASSWORD_HASH=(.*)$/m;
const passwordHashMatch = passwordHashRegex.exec(envContent);
const passwordHash = passwordHashMatch ? passwordHashMatch[1]?.trim() : null;
// Extract AUTH_ENABLED
const enabledRegex = /^AUTH_ENABLED=(.*)$/m;
const enabledMatch = enabledRegex.exec(envContent);
const enabled = enabledMatch ? enabledMatch[1]?.trim().toLowerCase() === 'true' : false;
// Extract AUTH_SETUP_COMPLETED
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=(.*)$/m;
const setupCompletedMatch = setupCompletedRegex.exec(envContent);
const setupCompleted = setupCompletedMatch ? setupCompletedMatch[1]?.trim().toLowerCase() === 'true' : false;
const hasCredentials = !!(username && passwordHash);
return {
username: username ?? null,
passwordHash: passwordHash ?? null,
enabled,
hasCredentials,
setupCompleted,
};
}
/**
* Update auth credentials in .env
*/
export async function updateAuthCredentials(
username: string,
password?: string,
enabled?: boolean
): Promise<void> {
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Hash the password if provided
const passwordHash = password ? await hashPassword(password) : null;
// Update or add AUTH_USERNAME
const usernameRegex = /^AUTH_USERNAME=.*$/m;
if (usernameRegex.test(envContent)) {
envContent = envContent.replace(usernameRegex, `AUTH_USERNAME=${username}`);
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_USERNAME=${username}\n`;
}
// Update or add AUTH_PASSWORD_HASH only if password is provided
if (passwordHash) {
const passwordHashRegex = /^AUTH_PASSWORD_HASH=.*$/m;
if (passwordHashRegex.test(envContent)) {
envContent = envContent.replace(passwordHashRegex, `AUTH_PASSWORD_HASH=${passwordHash}`);
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_PASSWORD_HASH=${passwordHash}\n`;
}
}
// Update or add AUTH_ENABLED if provided
if (enabled !== undefined) {
const enabledRegex = /^AUTH_ENABLED=.*$/m;
if (enabledRegex.test(envContent)) {
envContent = envContent.replace(enabledRegex, `AUTH_ENABLED=${enabled}`);
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_ENABLED=${enabled}\n`;
}
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
}
/**
* Set AUTH_SETUP_COMPLETED flag in .env
*/
export function setSetupCompleted(): void {
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_SETUP_COMPLETED
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=.*$/m;
if (setupCompletedRegex.test(envContent)) {
envContent = envContent.replace(setupCompletedRegex, 'AUTH_SETUP_COMPLETED=true');
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_SETUP_COMPLETED=true\n';
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
}
/**
* Update AUTH_ENABLED flag in .env
*/
export function updateAuthEnabled(enabled: boolean): void {
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_ENABLED
const enabledRegex = /^AUTH_ENABLED=.*$/m;
if (enabledRegex.test(envContent)) {
envContent = envContent.replace(enabledRegex, `AUTH_ENABLED=${enabled}`);
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_ENABLED=${enabled}\n`;
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
}