diff --git a/CHANGELOG.md b/CHANGELOG.md index c6bf8b03..c079d9b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added -- Optional passkey/WebAuthn sign-in for password-protected WebUI instances. Authenticated users can register/remove passkeys from Settings -> System, and `/login` shows a passwordless sign-in button only after a passkey exists. Password auth remains the default-off bootstrap and recovery path. +- Optional passkey/WebAuthn sign-in for password-protected WebUI instances. Authenticated users can register/remove passkeys from Settings -> System, switch to passkey-only/passwordless sign-in after at least one passkey exists, and `/login` shows a passwordless sign-in button only after a passkey exists. Password auth remains the default-off bootstrap and recovery path until users choose to remove it. ### Fixed diff --git a/README.md b/README.md index 56af9e10..9ba22ee4 100644 --- a/README.md +++ b/README.md @@ -468,7 +468,7 @@ Production data and real cron jobs are never touched. Current snapshot: - Optional password auth -- off by default, zero friction for localhost - Enable via `HERMES_WEBUI_PASSWORD` env var or Settings panel - Optional passkeys/WebAuthn -- register from Settings -> System after signing in with a password; the login page only shows passkey sign-in after at least one passkey exists -- Password auth remains the bootstrap/recovery path; passkeys are same-origin and stored locally in the WebUI state directory +- After registering at least one passkey, Settings -> System can remove the password and keep passkey-only sign-in enabled. Password auth remains the bootstrap/recovery path until you choose to go passwordless; passkeys are same-origin and stored locally in the WebUI state directory - Signed HMAC HTTP-only cookie with 24h TTL - Minimal dark-themed login page at `/login` - Security headers on all responses (X-Content-Type-Options, X-Frame-Options, Referrer-Policy) diff --git a/api/auth.py b/api/auth.py index 9232e2c7..db9a9d8a 100644 --- a/api/auth.py +++ b/api/auth.py @@ -1,7 +1,7 @@ """ -Hermes Web UI -- Optional password authentication. -Off by default. Enable by setting HERMES_WEBUI_PASSWORD env var -or configuring a password in the Settings panel. +Hermes Web UI -- optional authentication. +Off by default. Enable by setting HERMES_WEBUI_PASSWORD, configuring a +password in Settings, or registering passkeys and then going passwordless. """ import hashlib import hmac @@ -291,11 +291,27 @@ def get_password_hash() -> str | None: return result -def is_auth_enabled() -> bool: +def is_password_auth_enabled() -> bool: """True if a password is configured (env var or settings).""" return get_password_hash() is not None +def are_passkeys_enabled() -> bool: + """True if at least one local passkey credential is registered.""" + try: + from api.passkeys import passkeys_available + + return passkeys_available() + except Exception as exc: + logger.debug("Failed to inspect passkey availability: %s", exc) + return False + + +def is_auth_enabled() -> bool: + """True if password auth or passkey-only auth is configured.""" + return is_password_auth_enabled() or are_passkeys_enabled() + + def verify_password(plain: str) -> bool: """Verify a plaintext password against the stored hash. diff --git a/api/passkeys.py b/api/passkeys.py index dbfbb65d..2aadf980 100644 --- a/api/passkeys.py +++ b/api/passkeys.py @@ -357,3 +357,9 @@ def delete_credential(credential_id: str) -> dict[str, Any]: raise PasskeyError("Passkey not found") _save_credentials(kept) return {"ok": True, "credentials": registered_credentials()} + + +def clear_credentials() -> None: + """Remove all registered passkeys when the user disables all auth.""" + if _CREDENTIALS_FILE.exists(): + _save_credentials([]) diff --git a/api/routes.py b/api/routes.py index b6e030e1..723a995f 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3752,17 +3752,21 @@ def handle_get(handler, parsed) -> bool: return t(handler, _page, content_type="text/html; charset=utf-8") if parsed.path == "/api/auth/status": - from api.auth import is_auth_enabled, parse_cookie, verify_session + from api.auth import get_password_hash, is_auth_enabled, parse_cookie, verify_session from api.passkeys import registered_credentials logged_in = False - if is_auth_enabled(): + auth_enabled = is_auth_enabled() + if auth_enabled: cv = parse_cookie(handler) logged_in = bool(cv and verify_session(cv)) passkeys = registered_credentials() + password_auth_enabled = get_password_hash() is not None return j(handler, { - "auth_enabled": is_auth_enabled(), + "auth_enabled": auth_enabled, "logged_in": logged_in, + "password_auth_enabled": password_auth_enabled, + "passwordless_enabled": bool(passkeys) and not password_auth_enabled, "passkeys_enabled": bool(passkeys), "passkeys_count": len(passkeys), }) @@ -5874,7 +5878,10 @@ def handle_post(handler, parsed) -> bool: isinstance(body.get("_set_password"), str) and body.get("_set_password", "").strip() ) - requested_clear_password = bool(body.get("_clear_password")) + requested_passwordless = bool(body.pop("_passwordless", False)) + requested_clear_password = bool(body.get("_clear_password") or requested_passwordless) + if requested_passwordless: + body["_clear_password"] = True # #1560: HERMES_WEBUI_PASSWORD env var takes precedence in # api.auth.get_password_hash(), so writing password_hash to settings.json @@ -5889,6 +5896,15 @@ def handle_post(handler, parsed) -> bool: "Unset the env var and restart the server before changing the password here.", 409, ) + if requested_passwordless: + from api.passkeys import registered_credentials + + if not registered_credentials(): + return bad(handler, "Register a passkey before going passwordless.", 409) + elif requested_clear_password: + from api.passkeys import clear_credentials + + clear_credentials() saved = save_settings(body) saved.pop("password_hash", None) # never expose hash to client @@ -6473,9 +6489,14 @@ def handle_post(handler, parsed) -> bool: return bad(handler, str(e), status=400) if parsed.path == "/api/auth/passkey/delete": - from api.passkeys import PasskeyError, delete_credential + from api.auth import get_password_hash + from api.passkeys import PasskeyError, delete_credential, registered_credentials try: - return j(handler, delete_credential(str(body.get("id") or ""))) + credential_id = str(body.get("id") or "") + creds = registered_credentials() + if get_password_hash() is None and len(creds) <= 1 and any(c.get("id") == credential_id for c in creds): + return bad(handler, "Set a password or disable auth before removing the last passkey.", 409) + return j(handler, delete_credential(credential_id)) except PasskeyError as e: return bad(handler, str(e), status=404) diff --git a/static/index.html b/static/index.html index a84f01ea..3ac56ce0 100644 --- a/static/index.html +++ b/static/index.html @@ -1269,8 +1269,9 @@
-
Register this browser or device for passwordless sign-in. Password auth remains the recovery path.
+
Register this browser or device for passwordless sign-in. Once at least one passkey exists, you can remove the password and sign in with passkeys only.
+
No passkeys registered.
diff --git a/static/panels.js b/static/panels.js index ee9168fd..72e8c38b 100644 --- a/static/panels.js +++ b/static/panels.js @@ -6246,6 +6246,7 @@ async function loadSettingsPanel(){ // tells the truth before a user tries (and the backend now also returns // 409 as defense-in-depth). const pwEnvLocked=!!settings.password_env_var; + _settingsPasswordEnvLocked=pwEnvLocked; const pwLockBanner=$('settingsPasswordEnvLock'); if(pwField){ pwField.disabled=pwEnvLocked; @@ -6259,6 +6260,7 @@ async function loadSettingsPanel(){ try{ const authStatus=await api('/api/auth/status'); _setSettingsAuthButtonsVisible(!!authStatus.auth_enabled); + _syncPasswordlessButton(authStatus); }catch(e){} loadPasskeys(); // #1560: env-var-locked password also disables the Disable Auth button — @@ -6921,6 +6923,7 @@ async function _refreshProviderModels(providerId, btn){ } } +let _settingsPasswordEnvLocked=false; function _setSettingsAuthButtonsVisible(active){ const signOutBtn=$('btnSignOut'); if(signOutBtn) signOutBtn.style.display=active?'':'none'; @@ -6929,6 +6932,13 @@ function _setSettingsAuthButtonsVisible(active){ const passkeyBtn=$('btnRegisterPasskey'); if(passkeyBtn) passkeyBtn.disabled=!active||!window.PublicKeyCredential||!navigator.credentials; } +function _syncPasswordlessButton(authStatus){ + const btn=$('btnGoPasswordless'); + if(!btn) return; + const can=!!(authStatus&&authStatus.auth_enabled&&authStatus.password_auth_enabled&&authStatus.passkeys_count>0&&!_settingsPasswordEnvLocked); + btn.style.display=can?'':'none'; + btn.disabled=!can; +} function _b64uToBytes(s){ s=String(s||'').replace(/-/g,'+').replace(/_/g,'/'); @@ -6976,13 +6986,14 @@ async function registerPasskey(){ })}); showToast('Passkey registered'); loadPasskeys(); + try{_syncPasswordlessButton(await api('/api/auth/status'));}catch(_e){} }catch(e){showToast('Passkey registration failed: '+e.message);} } async function deletePasskey(id){ const ok=await showConfirmDialog({title:'Remove passkey?',message:'This browser/device will no longer be able to sign in with that passkey.',confirmLabel:'Remove',danger:true,focusCancel:true}); if(!ok) return; - try{await api('/api/auth/passkey/delete',{method:'POST',body:JSON.stringify({id})});showToast('Passkey removed');loadPasskeys();} + try{await api('/api/auth/passkey/delete',{method:'POST',body:JSON.stringify({id})});showToast('Passkey removed');loadPasskeys();try{_syncPasswordlessButton(await api('/api/auth/status'));}catch(_e){}} catch(e){showToast('Failed to remove passkey: '+e.message);} } @@ -7408,17 +7419,31 @@ async function signOut(){ } } +async function goPasswordless(){ + const ok=await showConfirmDialog({title:'Go passwordless?',message:'This removes the password and keeps passkey sign-in enabled. Keep at least one passkey registered or you could lose access.',confirmLabel:'Go passwordless',danger:false,focusCancel:true}); + if(!ok) return; + try{ + const saved=await api('/api/settings',{method:'POST',body:JSON.stringify({_passwordless:true})}); + showToast('Password removed. Passkey sign-in remains enabled.'); + _setSettingsAuthButtonsVisible(!!saved.auth_enabled); + _syncPasswordlessButton({auth_enabled:saved.auth_enabled,password_auth_enabled:false,passkeys_count:1}); + const pwField=$('settingsPassword'); if(pwField) pwField.value=''; + }catch(e){showToast('Failed to go passwordless: '+e.message);} +} + async function disableAuth(){ const _disAuth=await showConfirmDialog({title:t('disable_auth_confirm_title'),message:t('disable_auth_confirm_message'),confirmLabel:t('disable'),danger:true,focusCancel:true}); if(!_disAuth) return; try{ await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})}); showToast(t('auth_disabled')); - // Hide both auth buttons since auth is now off + // Hide auth controls since auth is now off const disableBtn=$('btnDisableAuth'); if(disableBtn) disableBtn.style.display='none'; const signOutBtn=$('btnSignOut'); if(signOutBtn) signOutBtn.style.display='none'; + _syncPasswordlessButton({auth_enabled:false,password_auth_enabled:false,passkeys_count:0}); + loadPasskeys(); }catch(e){ showToast(t('disable_auth_failed')+e.message); } diff --git a/tests/test_passkey_auth.py b/tests/test_passkey_auth.py index 37bfa7f2..10e7e1fb 100644 --- a/tests/test_passkey_auth.py +++ b/tests/test_passkey_auth.py @@ -137,6 +137,8 @@ def test_auth_status_reports_passkey_availability_source_contract(): src = open("api/routes.py", encoding="utf-8").read() assert '"passkeys_enabled"' in src assert '"passkeys_count"' in src + assert '"password_auth_enabled"' in src + assert '"passwordless_enabled"' in src assert 'registered_credentials()' in src @@ -147,3 +149,27 @@ def test_login_page_has_default_hidden_passkey_button_and_script_wiring(): assert 'style="display:none"' in routes assert "api/auth/passkey/options" in login_js assert "navigator.credentials.get" in login_js + + +def test_passwordless_mode_keeps_auth_enabled_with_passkeys(monkeypatch, tmp_path): + import api.auth as auth + passkeys = _set_paths(monkeypatch, tmp_path) + passkeys._save_credentials([{"id": "cred-1", "label": "This device"}]) + monkeypatch.setattr(auth, "get_password_hash", lambda: None) + + assert auth.are_passkeys_enabled() is True + assert auth.is_auth_enabled() is True + + +def test_passwordless_settings_and_last_passkey_guard_are_wired(): + routes = open("api/routes.py", encoding="utf-8").read() + panels = open("static/panels.js", encoding="utf-8").read() + index = open("static/index.html", encoding="utf-8").read() + + assert "_passwordless" in routes + assert "Register a passkey before going passwordless." in routes + assert "Set a password or disable auth before removing the last passkey." in routes + assert "clear_credentials()" in routes + assert "id=\"btnGoPasswordless\"" in index + assert "async function goPasswordless" in panels + assert "prompt(" not in panels