Update 2FA

This commit is contained in:
MacRimi
2026-02-07 18:03:46 +01:00
parent eab902d68e
commit 108a169e7c
4 changed files with 346 additions and 17 deletions

View File

@@ -5,7 +5,7 @@ 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 { 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, Wrench, Package, Key, Copy, Eye, EyeOff, Ruler } from 'lucide-react' import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Wrench, Package, Key, Copy, Eye, EyeOff, Ruler, Trash2, RefreshCw, Clock } from 'lucide-react'
import { APP_VERSION } from "./release-notes-modal" import { APP_VERSION } from "./release-notes-modal"
import { getApiUrl, fetchApi } from "../lib/api-config" import { getApiUrl, fetchApi } from "../lib/api-config"
import { TwoFactorSetup } from "./two-factor-setup" import { TwoFactorSetup } from "./two-factor-setup"
@@ -18,6 +18,15 @@ interface ProxMenuxTool {
enabled: boolean enabled: boolean
} }
interface ApiTokenEntry {
id: string
name: string
token_prefix: string
created_at: string
expires_at: string
revoked: boolean
}
export function Settings() { export function Settings() {
const [authEnabled, setAuthEnabled] = useState(false) const [authEnabled, setAuthEnabled] = useState(false)
const [totpEnabled, setTotpEnabled] = useState(false) const [totpEnabled, setTotpEnabled] = useState(false)
@@ -56,13 +65,20 @@ export function Settings() {
const [generatingToken, setGeneratingToken] = useState(false) const [generatingToken, setGeneratingToken] = useState(false)
const [tokenCopied, setTokenCopied] = useState(false) const [tokenCopied, setTokenCopied] = useState(false)
// Token list state
const [existingTokens, setExistingTokens] = useState<ApiTokenEntry[]>([])
const [loadingTokens, setLoadingTokens] = useState(false)
const [revokingTokenId, setRevokingTokenId] = useState<string | null>(null)
const [tokenName, setTokenName] = useState("API Token")
const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes") const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes")
const [loadingUnitSettings, setLoadingUnitSettings] = useState(true) const [loadingUnitSettings, setLoadingUnitSettings] = useState(true)
useEffect(() => { useEffect(() => {
checkAuthStatus() checkAuthStatus()
loadProxmenuxTools() loadProxmenuxTools()
getUnitsSettings() // Load units settings on mount loadApiTokens()
getUnitsSettings()
}, []) }, [])
const checkAuthStatus = async () => { const checkAuthStatus = async () => {
@@ -293,6 +309,47 @@ export function Settings() {
window.location.reload() window.location.reload()
} }
const loadApiTokens = async () => {
try {
setLoadingTokens(true)
const data = await fetchApi("/api/auth/api-tokens")
if (data.success) {
setExistingTokens(data.tokens || [])
}
} catch {
// Silently fail - tokens section is optional
} finally {
setLoadingTokens(false)
}
}
const handleRevokeToken = async (tokenId: string) => {
if (!confirm("Are you sure you want to revoke this token? Any integration using it will stop working immediately.")) {
return
}
setRevokingTokenId(tokenId)
setError("")
setSuccess("")
try {
const data = await fetchApi(`/api/auth/api-tokens/${tokenId}`, {
method: "DELETE",
})
if (data.success) {
setSuccess("Token revoked successfully")
setExistingTokens((prev) => prev.filter((t) => t.id !== tokenId))
} else {
setError(data.message || "Failed to revoke token")
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to revoke token")
} finally {
setRevokingTokenId(null)
}
}
const handleGenerateApiToken = async () => { const handleGenerateApiToken = async () => {
setError("") setError("")
setSuccess("") setSuccess("")
@@ -316,6 +373,7 @@ export function Settings() {
body: JSON.stringify({ body: JSON.stringify({
password: tokenPassword, password: tokenPassword,
totp_token: totpEnabled ? tokenTotpCode : undefined, totp_token: totpEnabled ? tokenTotpCode : undefined,
token_name: tokenName || "API Token",
}), }),
}) })
@@ -333,6 +391,8 @@ export function Settings() {
setSuccess("API token generated successfully! Make sure to copy it now as you won't be able to see it again.") setSuccess("API token generated successfully! Make sure to copy it now as you won't be able to see it again.")
setTokenPassword("") setTokenPassword("")
setTokenTotpCode("") setTokenTotpCode("")
setTokenName("API Token")
loadApiTokens()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to generate API token. Please try again.") setError(err instanceof Error ? err.message : "Failed to generate API token. Please try again.")
} finally { } finally {
@@ -340,10 +400,36 @@ export function Settings() {
} }
} }
const copyApiToken = () => { const copyToClipboard = async (text: string) => {
navigator.clipboard.writeText(apiToken) try {
setTokenCopied(true) if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
setTimeout(() => setTokenCopied(false), 2000) await navigator.clipboard.writeText(text)
} else {
// Fallback for non-secure contexts (HTTP on local network)
const textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.left = "-9999px"
textarea.style.top = "-9999px"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
document.execCommand("copy")
document.body.removeChild(textarea)
}
return true
} catch {
return false
}
}
const copyApiToken = async () => {
const ok = await copyToClipboard(apiToken)
if (ok) {
setTokenCopied(true)
setTimeout(() => setTokenCopied(false), 2000)
}
} }
const toggleVersion = (version: string) => { const toggleVersion = (version: string) => {
@@ -763,6 +849,22 @@ export function Settings() {
Enter your credentials to generate a new long-lived API token Enter your credentials to generate a new long-lived API token
</p> </p>
<div className="space-y-2">
<Label htmlFor="token-name">Token Name</Label>
<div className="relative">
<Key className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="token-name"
type="text"
placeholder="e.g. Homepage, Home Assistant"
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
className="pl-10"
disabled={generatingToken}
/>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="token-password">Password</Label> <Label htmlFor="token-password">Password</Label>
<div className="relative"> <div className="relative">
@@ -811,6 +913,7 @@ export function Settings() {
setShowApiTokenSection(false) setShowApiTokenSection(false)
setTokenPassword("") setTokenPassword("")
setTokenTotpCode("") setTokenTotpCode("")
setTokenName("API Token")
setError("") setError("")
}} }}
variant="outline" variant="outline"
@@ -896,6 +999,78 @@ export function Settings() {
</Button> </Button>
</div> </div>
)} )}
{/* Existing Tokens List */}
{!loadingTokens && existingTokens.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">Active Tokens</h3>
<Button
variant="ghost"
size="sm"
onClick={loadApiTokens}
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<RefreshCw className="h-3 w-3 mr-1" />
Refresh
</Button>
</div>
<div className="space-y-2">
{existingTokens.map((token) => (
<div
key={token.id}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg border border-border"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center flex-shrink-0">
<Key className="h-4 w-4 text-blue-500" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{token.name}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<code className="font-mono">{token.token_prefix}</code>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{token.created_at
? new Date(token.created_at).toLocaleDateString()
: "Unknown"}
</span>
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRevokeToken(token.id)}
disabled={revokingTokenId === token.id}
className="h-8 px-2 text-red-500 hover:text-red-400 hover:bg-red-500/10 flex-shrink-0"
>
{revokingTokenId === token.id ? (
<div className="animate-spin h-4 w-4 border-2 border-red-500 border-t-transparent rounded-full" />
) : (
<Trash2 className="h-4 w-4" />
)}
<span className="ml-1 text-xs hidden sm:inline">Revoke</span>
</Button>
</div>
))}
</div>
</div>
)}
{loadingTokens && (
<div className="flex items-center justify-center py-4">
<div className="animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full" />
<span className="ml-2 text-sm text-muted-foreground">Loading tokens...</span>
</div>
)}
{!loadingTokens && existingTokens.length === 0 && !showApiTokenSection && !apiToken && (
<div className="text-center py-4 text-sm text-muted-foreground">
No API tokens created yet
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

@@ -89,14 +89,34 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
} }
} }
const copyToClipboard = (text: string, type: "secret" | "codes") => { const copyToClipboard = async (text: string, type: "secret" | "codes") => {
navigator.clipboard.writeText(text) try {
if (type === "secret") { if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
setCopiedSecret(true) await navigator.clipboard.writeText(text)
setTimeout(() => setCopiedSecret(false), 2000) } else {
} else { // Fallback for non-secure contexts (HTTP)
setCopiedCodes(true) const textarea = document.createElement("textarea")
setTimeout(() => setCopiedCodes(false), 2000) textarea.value = text
textarea.style.position = "fixed"
textarea.style.left = "-9999px"
textarea.style.top = "-9999px"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
document.execCommand("copy")
document.body.removeChild(textarea)
}
if (type === "secret") {
setCopiedSecret(true)
setTimeout(() => setCopiedSecret(false), 2000)
} else {
setCopiedCodes(true)
setTimeout(() => setCopiedCodes(false), 2000)
}
} catch {
console.error("Failed to copy to clipboard")
} }
} }

View File

@@ -57,7 +57,9 @@ def load_auth_config():
"configured": bool, "configured": bool,
"totp_enabled": bool, # 2FA enabled flag "totp_enabled": bool, # 2FA enabled flag
"totp_secret": str, # TOTP secret key "totp_secret": str, # TOTP secret key
"backup_codes": list # List of backup codes "backup_codes": list, # List of backup codes
"api_tokens": list, # List of stored API token metadata
"revoked_tokens": list # List of revoked token hashes
} }
""" """
if not AUTH_CONFIG_FILE.exists(): if not AUTH_CONFIG_FILE.exists():
@@ -69,7 +71,9 @@ def load_auth_config():
"configured": False, "configured": False,
"totp_enabled": False, "totp_enabled": False,
"totp_secret": None, "totp_secret": None,
"backup_codes": [] "backup_codes": [],
"api_tokens": [],
"revoked_tokens": []
} }
try: try:
@@ -81,6 +85,8 @@ def load_auth_config():
config.setdefault("totp_enabled", False) config.setdefault("totp_enabled", False)
config.setdefault("totp_secret", None) config.setdefault("totp_secret", None)
config.setdefault("backup_codes", []) config.setdefault("backup_codes", [])
config.setdefault("api_tokens", [])
config.setdefault("revoked_tokens", [])
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}")
@@ -92,7 +98,9 @@ def load_auth_config():
"configured": False, "configured": False,
"totp_enabled": False, "totp_enabled": False,
"totp_secret": None, "totp_secret": None,
"backup_codes": [] "backup_codes": [],
"api_tokens": [],
"revoked_tokens": []
} }
@@ -141,11 +149,18 @@ def verify_token(token):
""" """
Verify a JWT token Verify a JWT token
Returns username if valid, None otherwise Returns username if valid, None otherwise
Also checks if the token has been revoked
""" """
if not JWT_AVAILABLE or not token: if not JWT_AVAILABLE or not token:
return None return None
try: try:
# Check if the token has been revoked
token_hash = hashlib.sha256(token.encode()).hexdigest()
config = load_auth_config()
if token_hash in config.get("revoked_tokens", []):
return None
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload.get('username') return payload.get('username')
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
@@ -156,6 +171,88 @@ def verify_token(token):
return None return None
def store_api_token_metadata(token, token_name="API Token"):
"""
Store API token metadata (hash, name, creation date) for listing and revocation.
The actual token is never stored - only a hash for identification.
"""
config = load_auth_config()
token_hash = hashlib.sha256(token.encode()).hexdigest()
token_id = token_hash[:16]
token_entry = {
"id": token_id,
"name": token_name,
"token_hash": token_hash,
"token_prefix": token[:12] + "...",
"created_at": datetime.utcnow().isoformat() + "Z",
"expires_at": (datetime.utcnow() + timedelta(days=365)).isoformat() + "Z"
}
config.setdefault("api_tokens", [])
config["api_tokens"].append(token_entry)
save_auth_config(config)
return token_entry
def list_api_tokens():
"""
List all stored API token metadata (no actual tokens are returned).
Returns list of token entries with id, name, prefix, creation and expiration dates.
"""
config = load_auth_config()
tokens = config.get("api_tokens", [])
revoked = set(config.get("revoked_tokens", []))
result = []
for t in tokens:
entry = {
"id": t.get("id"),
"name": t.get("name", "API Token"),
"token_prefix": t.get("token_prefix", "***"),
"created_at": t.get("created_at"),
"expires_at": t.get("expires_at"),
"revoked": t.get("token_hash") in revoked
}
result.append(entry)
return result
def revoke_api_token(token_id):
"""
Revoke an API token by its ID.
Adds the token hash to the revoked list so it fails verification.
Returns (success: bool, message: str)
"""
config = load_auth_config()
tokens = config.get("api_tokens", [])
target = None
for t in tokens:
if t.get("id") == token_id:
target = t
break
if not target:
return False, "Token not found"
token_hash = target.get("token_hash")
config.setdefault("revoked_tokens", [])
if token_hash in config["revoked_tokens"]:
return False, "Token is already revoked"
config["revoked_tokens"].append(token_hash)
# Remove from the active tokens list
config["api_tokens"] = [t for t in tokens if t.get("id") != token_id]
if save_auth_config(config):
return True, "Token revoked successfully"
else:
return False, "Failed to save configuration"
def get_auth_status(): def get_auth_status():
""" """
Get current authentication status Get current authentication status
@@ -243,6 +340,8 @@ def disable_auth():
config["totp_enabled"] = False config["totp_enabled"] = False
config["totp_secret"] = None config["totp_secret"] = None
config["backup_codes"] = [] config["backup_codes"] = []
config["api_tokens"] = []
config["revoked_tokens"] = []
if save_auth_config(config): if save_auth_config(config):
return True, "Authentication disabled" return True, "Authentication disabled"

View File

@@ -262,6 +262,9 @@ def generate_api_token():
'iat': datetime.datetime.utcnow() 'iat': datetime.datetime.utcnow()
}, auth_manager.JWT_SECRET, algorithm='HS256') }, auth_manager.JWT_SECRET, algorithm='HS256')
# Store token metadata for listing and revocation
auth_manager.store_api_token_metadata(api_token, token_name)
return jsonify({ return jsonify({
"success": True, "success": True,
"token": api_token, "token": api_token,
@@ -276,3 +279,35 @@ def generate_api_token():
except Exception as e: except Exception as e:
print(f"[ERROR] generate_api_token: {str(e)}") # Log error for debugging print(f"[ERROR] generate_api_token: {str(e)}") # Log error for debugging
return jsonify({"success": False, "message": f"Internal error: {str(e)}"}), 500 return jsonify({"success": False, "message": f"Internal error: {str(e)}"}), 500
@auth_bp.route('/api/auth/api-tokens', methods=['GET'])
def list_api_tokens():
"""List all generated API tokens (metadata only, no actual token values)"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token or not auth_manager.verify_token(token):
return jsonify({"success": False, "message": "Unauthorized"}), 401
tokens = auth_manager.list_api_tokens()
return jsonify({"success": True, "tokens": tokens})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/api-tokens/<token_id>', methods=['DELETE'])
def revoke_api_token_route(token_id):
"""Revoke an API token by its ID"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token or not auth_manager.verify_token(token):
return jsonify({"success": False, "message": "Unauthorized"}), 401
success, message = auth_manager.revoke_api_token(token_id)
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