mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2025-11-17 19:16:25 +00:00
Update AppImage
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
266
AppImage/components/two-factor-setup.tsx
Normal file
266
AppImage/components/two-factor-setup.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user