feat(dashboard-auth): add pluggable password (non-redirect) login

The dashboard auth gate was OAuth-only: a DashboardAuthProvider could
authenticate only via a redirect to an IDP (start_login -> /auth/callback
-> complete_login). There was no first-class path for username/password
auth, so self-hosters who just want a password on their dashboard had no
clean option short of an external OAuth IDP.

Extend the provider framework with a parallel, non-redirect front door
that converges on the same Session + cookie + refresh machinery:

  - base.py: add the optional supports_password flag and
    complete_password_login(username, password) -> Session (default
    raises NotImplementedError so an OAuth-only provider that forgets the
    flag fails loudly). Add InvalidCredentialsError. OAuth providers are
    unaffected (flag defaults False; the method is never called).
  - routes.py: add POST /auth/password-login, mirroring the cookie-minting
    tail of /auth/callback but skipping PKCE/state/code. Returns JSON
    {ok, next} (the form POSTs via fetch). Generic 401 for both unknown
    user and wrong password (no enumeration oracle); 404 hides whether a
    provider exists or supports passwords; per-IP sliding-window rate
    limit (10/min -> 429). /api/auth/providers now reports
    supports_password so the login page can branch.
  - middleware.py: allowlist /auth/password-login (a bootstrap route).
    verify/refresh/revoke/ws-tickets/logout need zero changes — a password
    session is just a Session with provider-minted opaque tokens.
  - login_page.py: render a credential form (instead of a redirect button)
    for supports_password providers, wired by a small inline script that
    POSTs to /auth/password-login and navigates on success. OAuth-only
    pages stay script-free.
This commit is contained in:
Ben
2026-06-04 17:26:18 +10:00
committed by Teknium
parent fe74a1acda
commit ed9e8ba097
5 changed files with 379 additions and 8 deletions

View File

@ -14,6 +14,7 @@ from hermes_cli.dashboard_auth.base import (
Session,
LoginStart,
InvalidCodeError,
InvalidCredentialsError,
ProviderError,
RefreshExpiredError,
assert_protocol_compliance,
@ -30,6 +31,7 @@ __all__ = [
"Session",
"LoginStart",
"InvalidCodeError",
"InvalidCredentialsError",
"ProviderError",
"RefreshExpiredError",
"assert_protocol_compliance",

View File

@ -55,6 +55,16 @@ class InvalidCodeError(Exception):
"""
class InvalidCredentialsError(Exception):
"""A username/password pair was rejected by a password provider.
Raised by :meth:`DashboardAuthProvider.complete_password_login`. The
``/auth/password-login`` route translates this to HTTP 401 with a
deliberately generic detail (never distinguishing "unknown user" from
"wrong password") so the endpoint can't be used as a username oracle.
"""
class RefreshExpiredError(Exception):
"""The refresh token is dead.
@ -94,11 +104,33 @@ class DashboardAuthProvider(ABC):
Subclasses MUST set ``name`` (lowercase identifier, stable forever)
and ``display_name`` (user-facing label on the login page).
Password (non-redirect) providers:
A provider that authenticates with a username + password instead of
an OAuth redirect sets ``supports_password = True`` and implements
``complete_password_login``. The login page then renders a
credential form (POSTing to ``/auth/password-login``) instead of a
"Log in with X" redirect button. Everything downstream of login —
``verify_session`` / ``refresh_session`` / ``revoke_session``, the
session cookies, the WS-ticket mint — is identical to the OAuth
path, because a password session is just a :class:`Session` with
provider-minted opaque tokens. The OAuth methods (``start_login`` /
``complete_login``) remain abstract; a pure-password provider that
will never be reached via the redirect flow may implement them as
stubs that raise ``NotImplementedError``.
"""
name: str = ""
display_name: str = ""
# When True, this provider authenticates via username + password
# (``complete_password_login``) rather than (or in addition to) the
# OAuth redirect flow. The login page renders a credential form for
# such providers; the ``/auth/password-login`` route dispatches to
# ``complete_password_login``. OAuth-only providers leave this False
# and are completely unaffected.
supports_password: bool = False
@abstractmethod
def start_login(self, *, redirect_uri: str) -> LoginStart: ...
@ -121,6 +153,36 @@ class DashboardAuthProvider(ABC):
@abstractmethod
def revoke_session(self, *, refresh_token: str) -> None: ...
def complete_password_login(
self, *, username: str, password: str
) -> "Session":
"""Verify a username/password pair and mint a :class:`Session`.
Only called when ``supports_password`` is True (the
``/auth/password-login`` route guards on the flag). The default
raises ``NotImplementedError`` so an OAuth-only provider that
forgets to set the flag fails loudly rather than silently
accepting credentials.
The returned ``Session`` carries provider-minted opaque
``access_token`` / ``refresh_token`` exactly like the OAuth path,
so all downstream session handling (cookies, verify, refresh,
ws-tickets, logout) is identical.
Failure semantics:
* ``InvalidCredentialsError`` — username/password rejected. The
route surfaces a generic 401 (no user-vs-password
distinction). Implementations SHOULD spend constant time on
unknown users (dummy hash verify) to avoid a timing oracle.
* ``ProviderError`` — the backing credential store is
unreachable (LDAP/DB down); the route surfaces 503.
"""
raise NotImplementedError(
f"{type(self).__name__} does not support password login "
"(set supports_password = True and override "
"complete_password_login)"
)
def assert_protocol_compliance(cls: type) -> None:
"""Raise ``TypeError`` if ``cls`` doesn't fully implement the provider protocol.

View File

@ -225,6 +225,56 @@ _LOGIN_HTML_TEMPLATE = """\
outline-offset: 3px;
}}
/* Password provider form — same visual language as the OAuth buttons:
squared inputs, hairline borders, amber focus ring. */
.provider-form {{
display: grid;
gap: 0.75rem;
text-align: left;
}}
.form-title {{
font-family: 'Rules Compressed', 'Collapse', sans-serif;
font-weight: 600;
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: color-mix(in srgb, var(--foreground) 70%, transparent);
}}
.field {{
display: grid;
gap: 0.3rem;
}}
.field-label {{
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
}}
.field-input {{
width: 100%;
box-sizing: border-box;
padding: 0.7rem 0.8rem;
background: color-mix(in srgb, #000000 25%, var(--background-base));
color: var(--foreground);
border: 1px solid var(--hairline-strong);
border-radius: 0;
font-family: 'Collapse', sans-serif;
font-size: 0.95rem;
}}
.field-input:focus-visible {{
outline: none;
border-color: var(--midground);
box-shadow: 0 0 0 1px var(--midground);
}}
.form-error {{
color: #ff6b6b;
font-size: 0.82rem;
letter-spacing: 0.02em;
}}
.provider-form .provider-btn {{
margin-top: 0.25rem;
}}
footer {{
margin-top: 1.75rem;
text-align: center;
@ -264,6 +314,7 @@ _LOGIN_HTML_TEMPLATE = """\
<span class="sep"></span>Public bind &middot; Auth required<span class="sep"></span>
</footer>
</main>
{password_script}
</body>
</html>
"""
@ -350,6 +401,60 @@ auth gate (not recommended on untrusted networks).</p>
"""
# Inline script that wires every password provider form to POST JSON to
# ``/auth/password-login`` and navigate on success. Emitted ONLY when at
# least one ``supports_password`` provider is listed (OAuth-only login
# pages stay script-free, preserving the no-JS contract for that case).
#
# Plain string (NOT run through ``str.format``), so braces are literal —
# do not double them. A single delegated submit handler covers all forms;
# the provider name is read from the form's ``data-provider`` attribute.
_PASSWORD_FORM_SCRIPT = """\
<script>
(function () {
function handle(form) {
form.addEventListener('submit', function (ev) {
ev.preventDefault();
var err = form.querySelector('.form-error');
var btn = form.querySelector('button[type=submit]');
if (err) { err.hidden = true; err.textContent = ''; }
if (btn) { btn.disabled = true; }
var body = {
provider: form.getAttribute('data-provider') || '',
username: (form.querySelector('input[name=username]') || {}).value || '',
password: (form.querySelector('input[name=password]') || {}).value || '',
next: (form.querySelector('input[name=next]') || {}).value || ''
};
fetch('/auth/password-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
credentials: 'same-origin'
}).then(function (resp) {
if (resp.ok) {
return resp.json().then(function (data) {
window.location.assign((data && data.next) || '/');
});
}
var msg = resp.status === 429
? 'Too many attempts. Please wait and try again.'
: (resp.status === 401 ? 'Invalid username or password.'
: 'Sign-in failed. Please try again.');
if (err) { err.textContent = msg; err.hidden = false; }
if (btn) { btn.disabled = false; }
}).catch(function () {
if (err) { err.textContent = 'Network error. Please try again.'; err.hidden = false; }
if (btn) { btn.disabled = false; }
});
});
}
var forms = document.querySelectorAll('form.provider-form');
for (var i = 0; i < forms.length; i++) { handle(forms[i]); }
})();
</script>
"""
def render_login_html(*, next_path: str = "") -> str:
"""Return the full HTML for ``GET /login``.
@ -375,10 +480,55 @@ def render_login_html(*, next_path: str = "") -> str:
next_qs = ""
buttons = []
needs_password_script = False
for p in providers:
buttons.append(
f' <a class="provider-btn" '
f'href="/auth/login?provider={html.escape(p.name, quote=True)}{next_qs}">'
f'Sign in with {html.escape(p.display_name)}</a>'
)
return _LOGIN_HTML_TEMPLATE.format(provider_buttons="\n".join(buttons))
if getattr(p, "supports_password", False):
needs_password_script = True
buttons.append(_render_password_form(p, next_path))
else:
buttons.append(
f' <a class="provider-btn" '
f'href="/auth/login?provider={html.escape(p.name, quote=True)}{next_qs}">'
f'Sign in with {html.escape(p.display_name)}</a>'
)
script = _PASSWORD_FORM_SCRIPT if needs_password_script else ""
return _LOGIN_HTML_TEMPLATE.format(
provider_buttons="\n".join(buttons),
password_script=script,
)
def _render_password_form(provider, next_path: str) -> str:
"""Render a username/password form for a ``supports_password`` provider.
The form is wired by :data:`_PASSWORD_FORM_SCRIPT` (a single delegated
submit handler) to POST JSON to ``/auth/password-login`` and navigate
on success. ``next_path`` is carried in a hidden field; it has already
been validated same-origin by the caller and is HTML-escaped here as
defence in depth. The provider ``name`` is emitted in a ``data-``
attribute (not a hidden input) so the script reads it without trusting
form-field ordering.
"""
pname = html.escape(provider.name, quote=True)
plabel = html.escape(provider.display_name)
safe_next = html.escape(next_path, quote=True) if next_path else ""
return (
f' <form class="provider-form" data-provider="{pname}" '
f'autocomplete="on">\n'
f' <div class="form-title">Sign in with {plabel}</div>\n'
f' <input type="hidden" name="next" value="{safe_next}">\n'
f' <label class="field">\n'
f' <span class="field-label">Username</span>\n'
f' <input class="field-input" type="text" name="username" '
f'autocomplete="username" autocapitalize="none" '
f'autocorrect="off" spellcheck="false" required>\n'
f' </label>\n'
f' <label class="field">\n'
f' <span class="field-label">Password</span>\n'
f' <input class="field-input" type="password" name="password" '
f'autocomplete="current-password" required>\n'
f' </label>\n'
f' <div class="form-error" role="alert" hidden></div>\n'
f' <button class="provider-btn" type="submit">Sign in</button>\n'
f' </form>'
)

View File

@ -38,6 +38,7 @@ _log = logging.getLogger(__name__)
_GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
"/auth/login",
"/auth/callback",
"/auth/password-login",
"/auth/logout",
"/login",
"/api/auth/providers",

View File

@ -16,11 +16,14 @@ The routes:
from __future__ import annotations
import logging
import threading
import time
from typing import Any
from collections import defaultdict, deque
from typing import Any, Deque, Dict, Tuple
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel
from hermes_cli.dashboard_auth import (
get_provider,
@ -29,6 +32,7 @@ from hermes_cli.dashboard_auth import (
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.base import (
InvalidCodeError,
InvalidCredentialsError,
ProviderError,
)
from hermes_cli.dashboard_auth.cookies import (
@ -154,7 +158,13 @@ async def api_auth_providers() -> Any:
)
return {
"providers": [
{"name": p.name, "display_name": p.display_name}
{
"name": p.name,
"display_name": p.display_name,
"supports_password": bool(
getattr(p, "supports_password", False)
),
}
for p in providers
],
}
@ -377,6 +387,152 @@ def _validate_post_login_target(raw: str) -> str:
return decoded
# ---------------------------------------------------------------------------
# Public: password (non-redirect) login
# ---------------------------------------------------------------------------
#
# Brute-force throttle. The OAuth flow has no guessable secret on our side
# (the IDP owns credentials), but ``/auth/password-login`` accepts a
# password we verify locally, so it's a credential-stuffing target. A
# simple in-process sliding-window limiter per client IP raises the cost
# of online guessing without any external dependency. It is intentionally
# best-effort: process-local (resets on restart), and behind a trusting
# proxy the IP is the proxy's unless X-Forwarded-For is set — which is why
# this is defence-in-depth on top of the provider's own constant-time
# verify, not the only line of defence.
_PW_RATE_MAX_ATTEMPTS = 10
_PW_RATE_WINDOW_SEC = 60.0
_pw_attempts: Dict[str, Deque[float]] = defaultdict(deque)
_pw_attempts_lock = threading.Lock()
def _password_rate_limited(ip: str) -> bool:
"""True if ``ip`` has exceeded the password-login attempt budget.
Sliding window: prune attempts older than the window, then check the
count. Records the attempt timestamp when allowed. An empty IP (no
discernible client) shares a single bucket — fail-safe toward
throttling rather than letting unattributable traffic through
unmetered.
"""
now = time.monotonic()
cutoff = now - _PW_RATE_WINDOW_SEC
key = ip or "_unknown_"
with _pw_attempts_lock:
bucket = _pw_attempts[key]
while bucket and bucket[0] < cutoff:
bucket.popleft()
if len(bucket) >= _PW_RATE_MAX_ATTEMPTS:
return True
bucket.append(now)
return False
def _reset_password_rate_limit() -> None:
"""Test-only: clear all rate-limit buckets."""
with _pw_attempts_lock:
_pw_attempts.clear()
class _PasswordLoginBody(BaseModel):
provider: str
username: str
password: str
next: str = ""
@router.post("/auth/password-login", name="auth_password_login")
async def auth_password_login(request: Request, body: _PasswordLoginBody):
"""Authenticate a username/password against a password provider.
Mirrors the cookie-minting tail of ``/auth/callback`` but skips the
PKCE/state/code machinery (those are OAuth-only). On success sets the
session cookies and returns JSON ``{"ok": true, "next": <path>}`` —
the credential form POSTs via fetch and navigates client-side, so a
302 (which fetch follows opaquely) is the wrong shape here.
Failure modes, all deliberately generic so the endpoint can't be used
as a username oracle or a provider-enumeration oracle:
* unknown provider / provider lacks password support → 404
* bad credentials → 401 ("Invalid credentials")
* backing store unreachable → 503
* too many attempts from this IP → 429
"""
ip = _client_ip(request)
if _password_rate_limited(ip):
audit_log(
AuditEvent.LOGIN_FAILURE,
provider=body.provider,
reason="rate_limited",
ip=ip,
)
raise HTTPException(
status_code=429,
detail="Too many login attempts. Try again shortly.",
)
p = get_provider(body.provider)
if p is None or not getattr(p, "supports_password", False):
# Don't leak which providers exist or which support passwords —
# same 404 whether the provider is unknown or OAuth-only.
audit_log(
AuditEvent.LOGIN_FAILURE,
provider=body.provider,
reason="unknown_password_provider",
ip=ip,
)
raise HTTPException(status_code=404, detail="Unknown provider")
try:
session = p.complete_password_login(
username=body.username, password=body.password
)
except InvalidCredentialsError:
audit_log(
AuditEvent.LOGIN_FAILURE,
provider=body.provider,
reason="invalid_credentials",
ip=ip,
)
# Generic message — never distinguish unknown-user from wrong-password.
raise HTTPException(status_code=401, detail="Invalid credentials")
except NotImplementedError:
# supports_password was True but the method isn't actually
# implemented — a provider bug, not a client error.
raise HTTPException(status_code=500, detail="Provider misconfigured")
except ProviderError as e:
audit_log(
AuditEvent.LOGIN_FAILURE,
provider=body.provider,
reason="provider_unreachable",
ip=ip,
)
raise HTTPException(status_code=503, detail=f"Provider unreachable: {e}")
audit_log(
AuditEvent.LOGIN_SUCCESS,
provider=body.provider,
user_id=session.user_id,
email=session.email,
org_id=session.org_id,
ip=ip,
)
expires_in = max(60, session.expires_at - int(time.time()))
landing = _validate_post_login_target(body.next) or "/"
resp = JSONResponse({"ok": True, "next": landing})
set_session_cookies(
resp,
access_token=session.access_token,
refresh_token=session.refresh_token,
access_token_expires_in=expires_in,
use_https=detect_https(request),
prefix=_prefix(request),
)
return resp
@router.post("/auth/logout", name="auth_logout")
async def auth_logout(request: Request):
_at, rt = read_session_cookies(request)