Update AppImage

This commit is contained in:
MacRimi
2025-11-07 20:36:46 +01:00
parent f2f9c37ee2
commit 5dd8b3ee36
5 changed files with 857 additions and 59 deletions

View File

@@ -2,11 +2,12 @@
import type React from "react" import type React from "react"
import { useState } from "react" import { useState, useEffect } from "react"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { Input } from "./ui/input" import { Input } from "./ui/input"
import { Label } from "./ui/label" import { Label } from "./ui/label"
import { Lock, User, AlertCircle, Server } from "lucide-react" import { Checkbox } from "./ui/checkbox"
import { Lock, User, AlertCircle, Server, Shield } from "lucide-react"
import { getApiUrl } from "../lib/api-config" import { getApiUrl } from "../lib/api-config"
import Image from "next/image" import Image from "next/image"
@@ -17,9 +18,23 @@ interface LoginProps {
export function Login({ onLogin }: LoginProps) { export function Login({ onLogin }: LoginProps) {
const [username, setUsername] = useState("") const [username, setUsername] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [totpCode, setTotpCode] = useState("")
const [requiresTotp, setRequiresTotp] = useState(false)
const [rememberMe, setRememberMe] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
useEffect(() => {
const savedUsername = localStorage.getItem("proxmenux-saved-username")
const savedPassword = localStorage.getItem("proxmenux-saved-password")
if (savedUsername && savedPassword) {
setUsername(savedUsername)
setPassword(savedPassword)
setRememberMe(true)
}
}, [])
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError("") setError("")
@@ -29,23 +44,46 @@ export function Login({ onLogin }: LoginProps) {
return return
} }
if (requiresTotp && !totpCode) {
setError("Please enter your 2FA code")
return
}
setLoading(true) setLoading(true)
try { try {
const response = await fetch(getApiUrl("/api/auth/login"), { const response = await fetch(getApiUrl("/api/auth/login"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), body: JSON.stringify({
username,
password,
totp_token: totpCode || undefined, // Include 2FA code if provided
}),
}) })
const data = await response.json() const data = await response.json()
if (!response.ok) { if (data.requires_totp) {
throw new Error(data.error || "Login failed") setRequiresTotp(true)
setLoading(false)
return
}
if (!response.ok) {
throw new Error(data.message || "Login failed")
} }
// Save token
localStorage.setItem("proxmenux-auth-token", data.token) localStorage.setItem("proxmenux-auth-token", data.token)
if (rememberMe) {
localStorage.setItem("proxmenux-saved-username", username)
localStorage.setItem("proxmenux-saved-password", password)
} else {
localStorage.removeItem("proxmenux-saved-username")
localStorage.removeItem("proxmenux-saved-password")
}
onLogin() onLogin()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Login failed") setError(err instanceof Error ? err.message : "Login failed")
@@ -94,46 +132,109 @@ export function Login({ onLogin }: LoginProps) {
</div> </div>
)} )}
<div className="space-y-2"> {!requiresTotp ? (
<Label htmlFor="login-username" className="text-sm"> <>
Username <div className="space-y-2">
</Label> <Label htmlFor="login-username" className="text-sm">
<div className="relative"> Username
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> </Label>
<Input <div className="relative">
id="login-username" <User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
type="text" <Input
placeholder="Enter your username" id="login-username"
value={username} type="text"
onChange={(e) => setUsername(e.target.value)} placeholder="Enter your username"
className="pl-10 text-base" value={username}
disabled={loading} onChange={(e) => setUsername(e.target.value)}
autoComplete="username" className="pl-10 text-base"
/> disabled={loading}
</div> autoComplete="username"
</div> />
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="login-password" className="text-sm"> <Label htmlFor="login-password" className="text-sm">
Password Password
</Label> </Label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
id="login-password" id="login-password"
type="password" type="password"
placeholder="Enter your password" placeholder="Enter your password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="pl-10 text-base" className="pl-10 text-base"
disabled={loading} disabled={loading}
autoComplete="current-password" autoComplete="current-password"
/> />
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="remember-me"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked as boolean)}
disabled={loading}
/>
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
Remember me
</Label>
</div>
</>
) : (
<div className="space-y-4">
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
<Shield className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-500">Autenticación de Dos Factores</p>
<p className="text-xs text-blue-500 mt-1">
Introduce el código de 6 dígitos de tu aplicación de autenticación
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="totp-code" className="text-sm">
Código de Autenticación
</Label>
<Input
id="totp-code"
type="text"
placeholder="000000"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
className="text-center text-lg tracking-widest font-mono text-base"
maxLength={6}
disabled={loading}
autoComplete="one-time-code"
autoFocus
/>
<p className="text-xs text-muted-foreground text-center">
También puedes usar un código de respaldo (formato: XXXX-XXXX)
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setRequiresTotp(false)
setTotpCode("")
setError("")
}}
className="w-full"
>
Volver al inicio de sesión
</Button>
</div> </div>
</div> )}
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}> <Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Signing in..." : "Sign In"} {loading ? "Signing in..." : requiresTotp ? "Verify Code" : "Sign In"}
</Button> </Button>
</form> </form>
</div> </div>

View File

@@ -7,9 +7,11 @@ import { Label } from "./ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut } from "lucide-react" import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut } from "lucide-react"
import { getApiUrl } from "../lib/api-config" import { getApiUrl } from "../lib/api-config"
import { TwoFactorSetup } from "./two-factor-setup"
export function Settings() { export function Settings() {
const [authEnabled, setAuthEnabled] = useState(false) const [authEnabled, setAuthEnabled] = useState(false)
const [totpEnabled, setTotpEnabled] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [success, setSuccess] = useState("") const [success, setSuccess] = useState("")
@@ -26,6 +28,10 @@ export function Settings() {
const [newPassword, setNewPassword] = useState("") const [newPassword, setNewPassword] = useState("")
const [confirmNewPassword, setConfirmNewPassword] = useState("") const [confirmNewPassword, setConfirmNewPassword] = useState("")
const [show2FASetup, setShow2FASetup] = useState(false)
const [show2FADisable, setShow2FADisable] = useState(false)
const [disable2FAPassword, setDisable2FAPassword] = useState("")
useEffect(() => { useEffect(() => {
checkAuthStatus() checkAuthStatus()
}, []) }, [])
@@ -35,6 +41,7 @@ export function Settings() {
const response = await fetch(getApiUrl("/api/auth/status")) const response = await fetch(getApiUrl("/api/auth/status"))
const data = await response.json() const data = await response.json()
setAuthEnabled(data.auth_enabled || false) setAuthEnabled(data.auth_enabled || false)
setTotpEnabled(data.totp_enabled || false) // Get 2FA status
} catch (err) { } catch (err) {
console.error("Failed to check auth status:", err) console.error("Failed to check auth status:", err)
} }
@@ -196,6 +203,46 @@ export function Settings() {
} }
} }
const handleDisable2FA = async () => {
setError("")
setSuccess("")
if (!disable2FAPassword) {
setError("Please enter your password")
return
}
setLoading(true)
try {
const token = localStorage.getItem("proxmenux-auth-token")
const response = await fetch(getApiUrl("/api/auth/totp/disable"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ password: disable2FAPassword }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Failed to disable 2FA")
}
setSuccess("2FA disabled successfully!")
setTotpEnabled(false)
setShow2FADisable(false)
setDisable2FAPassword("")
checkAuthStatus()
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to disable 2FA")
} finally {
setLoading(false)
}
}
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem("proxmenux-auth-token") localStorage.removeItem("proxmenux-auth-token")
localStorage.removeItem("proxmenux-auth-setup-complete") localStorage.removeItem("proxmenux-auth-setup-complete")
@@ -418,6 +465,74 @@ export function Settings() {
</div> </div>
)} )}
{!totpEnabled && (
<Button
onClick={() => setShow2FASetup(true)}
variant="outline"
className="w-full bg-blue-500/10 hover:bg-blue-500/20 border-blue-500/20"
>
<Shield className="h-4 w-4 mr-2" />
Habilitar Autenticación de Dos Factores
</Button>
)}
{totpEnabled && (
<div className="space-y-3">
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3 flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<p className="text-sm text-green-500 font-medium">2FA está activado</p>
</div>
{!show2FADisable && (
<Button onClick={() => setShow2FADisable(true)} variant="outline" className="w-full">
<Shield className="h-4 w-4 mr-2" />
Desactivar 2FA
</Button>
)}
{show2FADisable && (
<div className="space-y-4 border border-border rounded-lg p-4">
<h3 className="font-semibold">Desactivar Autenticación de Dos Factores</h3>
<p className="text-sm text-muted-foreground">Introduce tu contraseña para confirmar</p>
<div className="space-y-2">
<Label htmlFor="disable-2fa-password">Contraseña</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="disable-2fa-password"
type="password"
placeholder="Introduce tu contraseña"
value={disable2FAPassword}
onChange={(e) => setDisable2FAPassword(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleDisable2FA} variant="destructive" className="flex-1" disabled={loading}>
{loading ? "Desactivando..." : "Desactivar 2FA"}
</Button>
<Button
onClick={() => {
setShow2FADisable(false)
setDisable2FAPassword("")
setError("")
}}
variant="outline"
className="flex-1"
disabled={loading}
>
Cancelar
</Button>
</div>
</div>
)}
</div>
)}
<Button onClick={handleDisableAuth} variant="destructive" className="w-full" disabled={loading}> <Button onClick={handleDisableAuth} variant="destructive" className="w-full" disabled={loading}>
Disable Authentication Disable Authentication
</Button> </Button>
@@ -443,6 +558,15 @@ export function Settings() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<TwoFactorSetup
open={show2FASetup}
onClose={() => setShow2FASetup(false)}
onSuccess={() => {
setSuccess("2FA habilitado correctamente!")
checkAuthStatus()
}}
/>
</div> </div>
) )
} }

View File

@@ -0,0 +1,266 @@
"use client"
import { useState } from "react"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import Image from "next/image"
interface TwoFactorSetupProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps) {
const [step, setStep] = useState(1)
const [qrCode, setQrCode] = useState("")
const [secret, setSecret] = useState("")
const [backupCodes, setBackupCodes] = useState<string[]>([])
const [verificationCode, setVerificationCode] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const [copiedSecret, setCopiedSecret] = useState(false)
const [copiedCodes, setCopiedCodes] = useState(false)
const handleSetupStart = async () => {
setError("")
setLoading(true)
try {
const token = localStorage.getItem("proxmenux-auth-token")
const response = await fetch(getApiUrl("/api/auth/totp/setup"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Failed to setup 2FA")
}
setQrCode(data.qr_code)
setSecret(data.secret)
setBackupCodes(data.backup_codes)
setStep(2)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to setup 2FA")
} finally {
setLoading(false)
}
}
const handleVerify = async () => {
if (!verificationCode || verificationCode.length !== 6) {
setError("Please enter a 6-digit code")
return
}
setError("")
setLoading(true)
try {
const token = localStorage.getItem("proxmenux-auth-token")
const response = await fetch(getApiUrl("/api/auth/totp/enable"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ token: verificationCode }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Invalid verification code")
}
setStep(3)
} catch (err) {
setError(err instanceof Error ? err.message : "Verification failed")
} finally {
setLoading(false)
}
}
const copyToClipboard = (text: string, type: "secret" | "codes") => {
navigator.clipboard.writeText(text)
if (type === "secret") {
setCopiedSecret(true)
setTimeout(() => setCopiedSecret(false), 2000)
} else {
setCopiedCodes(true)
setTimeout(() => setCopiedCodes(false), 2000)
}
}
const handleClose = () => {
setStep(1)
setQrCode("")
setSecret("")
setBackupCodes([])
setVerificationCode("")
setError("")
onClose()
}
const handleFinish = () => {
handleClose()
onSuccess()
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
Configurar Autenticación de Dos Factores
</DialogTitle>
<DialogDescription>Añade una capa extra de seguridad a tu cuenta</DialogDescription>
</DialogHeader>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-500">{error}</p>
</div>
)}
{step === 1 && (
<div className="space-y-4">
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<p className="text-sm text-blue-500">
La autenticación de dos factores (2FA) añade una capa extra de seguridad requiriendo un código de tu
aplicación de autenticación además de tu contraseña.
</p>
</div>
<div className="space-y-2">
<h4 className="font-medium">Necesitarás:</h4>
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
<li>Una aplicación de autenticación (Google Authenticator, Authy, etc.)</li>
<li>Escanear un código QR o introducir una clave manualmente</li>
<li>Guardar códigos de respaldo de forma segura</li>
</ul>
</div>
<Button onClick={handleSetupStart} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Iniciando..." : "Comenzar Configuración"}
</Button>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium">1. Escanea el código QR</h4>
<p className="text-sm text-muted-foreground">
Abre tu aplicación de autenticación y escanea este código QR
</p>
{qrCode && (
<div className="flex justify-center p-4 bg-white rounded-lg">
<Image src={qrCode || "/placeholder.svg"} alt="QR Code" width={200} height={200} />
</div>
)}
</div>
<div className="space-y-2">
<h4 className="font-medium">O introduce la clave manualmente:</h4>
<div className="flex gap-2">
<Input value={secret} readOnly className="font-mono text-sm" />
<Button
variant="outline"
size="icon"
onClick={() => copyToClipboard(secret, "secret")}
title="Copiar clave"
>
{copiedSecret ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium">2. Introduce el código de verificación</h4>
<p className="text-sm text-muted-foreground">
Introduce el código de 6 dígitos que aparece en tu aplicación
</p>
<Input
type="text"
placeholder="000000"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
className="text-center text-lg tracking-widest font-mono"
maxLength={6}
disabled={loading}
/>
</div>
<div className="flex gap-2">
<Button onClick={handleVerify} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Verificando..." : "Verificar y Activar"}
</Button>
<Button onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
Cancelar
</Button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4 flex items-start gap-2">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-green-500">2FA Activado Correctamente</p>
<p className="text-sm text-green-500 mt-1">
Tu cuenta ahora está protegida con autenticación de dos factores
</p>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-orange-500">Importante: Guarda tus códigos de respaldo</h4>
<p className="text-sm text-muted-foreground">
Estos códigos te permitirán acceder a tu cuenta si pierdes acceso a tu aplicación de autenticación.
Guárdalos en un lugar seguro.
</p>
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium">Códigos de Respaldo</span>
<Button variant="outline" size="sm" onClick={() => copyToClipboard(backupCodes.join("\n"), "codes")}>
{copiedCodes ? (
<Check className="h-4 w-4 text-green-500 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Copiar Todos
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
{backupCodes.map((code, index) => (
<div key={index} className="bg-background rounded px-3 py-2 font-mono text-sm text-center">
{code}
</div>
))}
</div>
</div>
</div>
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
Finalizar
</Button>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -5,11 +5,13 @@ Handles all authentication-related operations including:
- Password hashing and verification - Password hashing and verification
- JWT token generation and validation - JWT token generation and validation
- Auth status checking - Auth status checking
- Two-Factor Authentication (2FA/TOTP)
""" """
import os import os
import json import json
import hashlib import hashlib
import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -20,6 +22,16 @@ except ImportError:
JWT_AVAILABLE = False JWT_AVAILABLE = False
print("Warning: PyJWT not available. Authentication features will be limited.") print("Warning: PyJWT not available. Authentication features will be limited.")
try:
import pyotp
import qrcode
import io
import base64
TOTP_AVAILABLE = True
except ImportError:
TOTP_AVAILABLE = False
print("Warning: pyotp/qrcode not available. 2FA features will be disabled.")
# Configuration # Configuration
CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor" CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor"
AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json" AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json"
@@ -41,8 +53,11 @@ def load_auth_config():
"enabled": bool, "enabled": bool,
"username": str, "username": str,
"password_hash": str, "password_hash": str,
"declined": bool, # True if user explicitly declined auth "declined": bool,
"configured": bool # True if auth has been set up (enabled or declined) "configured": bool,
"totp_enabled": bool, # 2FA enabled flag
"totp_secret": str, # TOTP secret key
"backup_codes": list # List of backup codes
} }
""" """
if not AUTH_CONFIG_FILE.exists(): if not AUTH_CONFIG_FILE.exists():
@@ -51,7 +66,10 @@ def load_auth_config():
"username": None, "username": None,
"password_hash": None, "password_hash": None,
"declined": False, "declined": False,
"configured": False "configured": False,
"totp_enabled": False,
"totp_secret": None,
"backup_codes": []
} }
try: try:
@@ -60,6 +78,9 @@ def load_auth_config():
# Ensure all required fields exist # Ensure all required fields exist
config.setdefault("declined", False) config.setdefault("declined", False)
config.setdefault("configured", config.get("enabled", False) or config.get("declined", False)) config.setdefault("configured", config.get("enabled", False) or config.get("declined", False))
config.setdefault("totp_enabled", False)
config.setdefault("totp_secret", None)
config.setdefault("backup_codes", [])
return config return config
except Exception as e: except Exception as e:
print(f"Error loading auth config: {e}") print(f"Error loading auth config: {e}")
@@ -68,7 +89,10 @@ def load_auth_config():
"username": None, "username": None,
"password_hash": None, "password_hash": None,
"declined": False, "declined": False,
"configured": False "configured": False,
"totp_enabled": False,
"totp_secret": None,
"backup_codes": []
} }
@@ -141,16 +165,18 @@ def get_auth_status():
"auth_configured": bool, "auth_configured": bool,
"declined": bool, "declined": bool,
"username": str or None, "username": str or None,
"authenticated": bool "authenticated": bool,
"totp_enabled": bool # 2FA status
} }
""" """
config = load_auth_config() config = load_auth_config()
return { return {
"auth_enabled": config.get("enabled", False), "auth_enabled": config.get("enabled", False),
"auth_configured": config.get("configured", False), # Frontend expects this field name "auth_configured": config.get("configured", False),
"declined": config.get("declined", False), "declined": config.get("declined", False),
"username": config.get("username") if config.get("enabled") else None, "username": config.get("username") if config.get("enabled") else None,
"authenticated": False # Will be set to True by the route handler if token is valid "authenticated": False,
"totp_enabled": config.get("totp_enabled", False) # Include 2FA status
} }
@@ -170,7 +196,10 @@ def setup_auth(username, password):
"username": username, "username": username,
"password_hash": hash_password(password), "password_hash": hash_password(password),
"declined": False, "declined": False,
"configured": True "configured": True,
"totp_enabled": False,
"totp_secret": None,
"backup_codes": []
} }
if save_auth_config(config): if save_auth_config(config):
@@ -190,6 +219,9 @@ def decline_auth():
config["configured"] = True config["configured"] = True
config["username"] = None config["username"] = None
config["password_hash"] = None config["password_hash"] = None
config["totp_enabled"] = False
config["totp_secret"] = None
config["backup_codes"] = []
if save_auth_config(config): if save_auth_config(config):
return True, "Authentication declined" return True, "Authentication declined"
@@ -208,6 +240,9 @@ def disable_auth():
config["password_hash"] = None config["password_hash"] = None
config["declined"] = False config["declined"] = False
config["configured"] = False config["configured"] = False
config["totp_enabled"] = False
config["totp_secret"] = None
config["backup_codes"] = []
if save_auth_config(config): if save_auth_config(config):
return True, "Authentication disabled" return True, "Authentication disabled"
@@ -258,24 +293,215 @@ def change_password(old_password, new_password):
return False, "Failed to save new password" return False, "Failed to save new password"
def authenticate(username, password): def generate_totp_secret():
"""Generate a new TOTP secret key"""
if not TOTP_AVAILABLE:
return None
return pyotp.random_base32()
def generate_totp_qr(username, secret):
""" """
Authenticate a user with username and password Generate a QR code for TOTP setup
Returns (success: bool, token: str or None, message: str) Returns base64 encoded PNG image
"""
if not TOTP_AVAILABLE:
return None
try:
# Create TOTP URI
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(
name=username,
issuer_name="ProxMenux Monitor"
)
# Generate QR code
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Convert to base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
img_base64 = base64.b64encode(buffer.getvalue()).decode()
return f"data:image/png;base64,{img_base64}"
except Exception as e:
print(f"Error generating QR code: {e}")
return None
def generate_backup_codes(count=8):
"""Generate backup codes for 2FA recovery"""
codes = []
for _ in range(count):
# Generate 8-character alphanumeric code
code = ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(8))
# Format as XXXX-XXXX for readability
formatted = f"{code[:4]}-{code[4:]}"
codes.append({
"code": hashlib.sha256(formatted.encode()).hexdigest(),
"used": False
})
return codes
def setup_totp(username):
"""
Set up TOTP for a user
Returns (success: bool, secret: str, qr_code: str, backup_codes: list, message: str)
"""
if not TOTP_AVAILABLE:
return False, None, None, None, "2FA is not available (pyotp/qrcode not installed)"
config = load_auth_config()
if not config.get("enabled"):
return False, None, None, None, "Authentication must be enabled first"
if config.get("username") != username:
return False, None, None, None, "Invalid username"
# Generate new secret and backup codes
secret = generate_totp_secret()
qr_code = generate_totp_qr(username, secret)
backup_codes_plain = []
backup_codes_hashed = generate_backup_codes()
# Generate plain text backup codes for display (only returned once)
for i in range(8):
code = ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(8))
formatted = f"{code[:4]}-{code[4:]}"
backup_codes_plain.append(formatted)
backup_codes_hashed[i]["code"] = hashlib.sha256(formatted.encode()).hexdigest()
# Store secret and hashed backup codes (not enabled yet until verified)
config["totp_secret"] = secret
config["backup_codes"] = backup_codes_hashed
if save_auth_config(config):
return True, secret, qr_code, backup_codes_plain, "2FA setup initiated"
else:
return False, None, None, None, "Failed to save 2FA configuration"
def verify_totp(username, token, use_backup=False):
"""
Verify a TOTP token or backup code
Returns (success: bool, message: str)
"""
if not TOTP_AVAILABLE and not use_backup:
return False, "2FA is not available"
config = load_auth_config()
if not config.get("totp_enabled"):
return False, "2FA is not enabled"
if config.get("username") != username:
return False, "Invalid username"
# Check backup code
if use_backup:
token_hash = hashlib.sha256(token.encode()).hexdigest()
for backup_code in config.get("backup_codes", []):
if backup_code["code"] == token_hash and not backup_code["used"]:
backup_code["used"] = True
save_auth_config(config)
return True, "Backup code accepted"
return False, "Invalid or already used backup code"
# Check TOTP token
totp = pyotp.TOTP(config.get("totp_secret"))
if totp.verify(token, valid_window=1): # Allow 1 time step tolerance
return True, "2FA verification successful"
else:
return False, "Invalid 2FA code"
def enable_totp(username, verification_token):
"""
Enable TOTP after successful verification
Returns (success: bool, message: str)
"""
if not TOTP_AVAILABLE:
return False, "2FA is not available"
config = load_auth_config()
if not config.get("totp_secret"):
return False, "2FA has not been set up. Please set up 2FA first."
if config.get("username") != username:
return False, "Invalid username"
# Verify the token before enabling
totp = pyotp.TOTP(config.get("totp_secret"))
if not totp.verify(verification_token, valid_window=1):
return False, "Invalid verification code. Please try again."
config["totp_enabled"] = True
if save_auth_config(config):
return True, "2FA enabled successfully"
else:
return False, "Failed to enable 2FA"
def disable_totp(username, password):
"""
Disable TOTP (requires password confirmation)
Returns (success: bool, message: str)
"""
config = load_auth_config()
if config.get("username") != username:
return False, "Invalid username"
if not verify_password(password, config.get("password_hash", "")):
return False, "Invalid password"
config["totp_enabled"] = False
config["totp_secret"] = None
config["backup_codes"] = []
if save_auth_config(config):
return True, "2FA disabled successfully"
else:
return False, "Failed to disable 2FA"
def authenticate(username, password, totp_token=None):
"""
Authenticate a user with username, password, and optional TOTP
Returns (success: bool, token: str or None, requires_totp: bool, message: str)
""" """
config = load_auth_config() config = load_auth_config()
if not config.get("enabled"): if not config.get("enabled"):
return False, None, "Authentication is not enabled" return False, None, False, "Authentication is not enabled"
if username != config.get("username"): if username != config.get("username"):
return False, None, "Invalid username or password" return False, None, False, "Invalid username or password"
if not verify_password(password, config.get("password_hash", "")): if not verify_password(password, config.get("password_hash", "")):
return False, None, "Invalid username or password" return False, None, False, "Invalid username or password"
if config.get("totp_enabled"):
if not totp_token:
return False, None, True, "2FA code required"
# Verify TOTP token or backup code
success, message = verify_totp(username, totp_token, use_backup=len(totp_token) == 9) # Backup codes are formatted XXXX-XXXX
if not success:
return False, None, True, message
token = generate_token(username) token = generate_token(username)
if token: if token:
return True, token, "Authentication successful" return True, token, False, "Authentication successful"
else: else:
return False, None, "Failed to generate authentication token" return False, None, False, "Failed to generate authentication token"

View File

@@ -64,11 +64,14 @@ def auth_login():
data = request.json data = request.json
username = data.get('username') username = data.get('username')
password = data.get('password') password = data.get('password')
totp_token = data.get('totp_token') # Optional 2FA token
success, token, message = auth_manager.authenticate(username, password) success, token, requires_totp, message = auth_manager.authenticate(username, password, totp_token)
if success: if success:
return jsonify({"success": True, "token": token, "message": message}) return jsonify({"success": True, "token": token, "message": message})
elif requires_totp:
return jsonify({"success": False, "requires_totp": True, "message": message}), 200
else: else:
return jsonify({"success": False, "message": message}), 401 return jsonify({"success": False, "message": message}), 401
except Exception as e: except Exception as e:
@@ -137,3 +140,81 @@ def auth_skip():
return jsonify({"success": False, "message": message}), 400 return jsonify({"success": False, "message": message}), 400
except Exception as e: except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500 return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/totp/setup', methods=['POST'])
def totp_setup():
"""Initialize TOTP setup for a user"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
username = auth_manager.verify_token(token)
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
success, secret, qr_code, backup_codes, message = auth_manager.setup_totp(username)
if success:
return jsonify({
"success": True,
"secret": secret,
"qr_code": qr_code,
"backup_codes": backup_codes,
"message": message
})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/totp/enable', methods=['POST'])
def totp_enable():
"""Enable TOTP after verification"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
username = auth_manager.verify_token(token)
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
data = request.json
verification_token = data.get('token')
if not verification_token:
return jsonify({"success": False, "message": "Verification token required"}), 400
success, message = auth_manager.enable_totp(username, verification_token)
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/totp/disable', methods=['POST'])
def totp_disable():
"""Disable TOTP (requires password confirmation)"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
username = auth_manager.verify_token(token)
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
data = request.json
password = data.get('password')
if not password:
return jsonify({"success": False, "message": "Password required"}), 400
success, message = auth_manager.disable_totp(username, password)
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500