feat: support passkey-only auth
This commit is contained in:
1 parent
7c257ae8f9
commit
1b48643f63
8 files changed
+110
-15
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user