feat: Sprint 19 — password auth, security headers, login page

Auth system (off by default, zero friction for localhost):
- New api/auth.py module: password hashing (SHA-256 + STATE_DIR salt),
  signed HMAC session cookies (24h TTL), auth middleware
- Enable via HERMES_WEBUI_PASSWORD env var or Settings panel
- Minimal dark-themed login page at /login (self-contained HTML)
- POST /api/auth/login, /api/auth/logout, GET /api/auth/status
- Settings panel: "Access Password" field + "Sign Out" button
- password_hash added to settings.json (null = auth disabled)

Security hardening:
- Security headers on all responses: X-Content-Type-Options: nosniff,
  X-Frame-Options: DENY, Referrer-Policy: same-origin
- POST body size limit: 20MB cap in read_body() to prevent DoS

Closes #23. 9 new tests. Total: 304 passed, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Esquenazi
2026-04-03 05:53:26 -07:00
parent 1c6db07c2b
commit b8b62722ec
8 changed files with 417 additions and 1 deletions

View File

@@ -649,6 +649,15 @@ async function loadSettingsPanel(){
// Send key preference
const sendKeySel=$('settingsSendKey');
if(sendKeySel) sendKeySel.value=settings.send_key||'enter';
// Password field: always blank (we don't send hash back)
const pwField=$('settingsPassword');
if(pwField) pwField.value='';
// Show sign-out button if auth is active
try{
const authStatus=await api('/api/auth/status');
const signOutBtn=$('btnSignOut');
if(signOutBtn) signOutBtn.style.display=authStatus.auth_enabled?'':'none';
}catch(e){}
}catch(e){
showToast('Failed to load settings: '+e.message);
}
@@ -658,10 +667,28 @@ async function saveSettings(){
const model=($('settingsModel')||{}).value;
const workspace=($('settingsWorkspace')||{}).value;
const sendKey=($('settingsSendKey')||{}).value;
const pw=($('settingsPassword')||{}).value;
const body={};
if(model) body.default_model=model;
if(workspace) body.default_workspace=workspace;
if(sendKey) body.send_key=sendKey;
// Password: if field has content, hash and save; if blank, clear auth
if(pw!==undefined&&pw!==null){
if(pw.trim()){
// Hash client-side using the same algo as server (SHA-256 with state-dir salt)
// We send the raw password to the server's dedicated endpoint instead
try{
await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
window._sendKey=sendKey||'enter';
showToast('Settings saved (password set — login now required)');
toggleSettings();
return;
}catch(e){showToast('Save failed: '+e.message);return;}
}else{
// Blank = clear password (disable auth)
body.password_hash=null;
}
}
try{
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
window._sendKey=sendKey||'enter';
@@ -672,6 +699,15 @@ async function saveSettings(){
}
}
async function signOut(){
try{
await api('/api/auth/logout',{method:'POST',body:'{}'});
window.location.href='/login';
}catch(e){
showToast('Sign out failed: '+e.message);
}
}
// Close settings on overlay click (not panel click)
document.addEventListener('click',e=>{
const overlay=$('settingsOverlay');