feat: support passkey-only auth

This commit is contained in:
AJV20 authored and nesquena-hermes committed 2026-05-25 00:14:38 +00:00
1 parent 7c257ae8f9
commit 1b48643f63
8 files changed
+110 -15

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+20 -4
View File
@@ -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.
+6
View File
@@ -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([])
+27 -6
View File
@@ -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)
+2 -1
View File
@@ -1269,8 +1269,9 @@
</div>
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
<label>Passkeys</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">Register this browser or device for passwordless sign-in. Password auth remains the recovery path.</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">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.</div>
<button class="sm-btn" id="btnRegisterPasskey" onclick="registerPasskey()" style="width:100%;padding:8px;font-weight:600">Add passkey</button>
<button class="sm-btn" id="btnGoPasswordless" onclick="goPasswordless()" style="display:none;margin-top:8px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3)">Go passwordless</button>
<div id="passkeyList" style="margin-top:8px;font-size:12px;color:var(--muted)">No passkeys registered.</div>
</div>
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
+27 -2
View File
@@ -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);
}
+26
View File
@@ -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