diff --git a/hermes_cli/dashboard_auth/__init__.py b/hermes_cli/dashboard_auth/__init__.py index 4a5c68b6e..faba37610 100644 --- a/hermes_cli/dashboard_auth/__init__.py +++ b/hermes_cli/dashboard_auth/__init__.py @@ -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", diff --git a/hermes_cli/dashboard_auth/base.py b/hermes_cli/dashboard_auth/base.py index 207c7c602..06dab5dd5 100644 --- a/hermes_cli/dashboard_auth/base.py +++ b/hermes_cli/dashboard_auth/base.py @@ -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. diff --git a/hermes_cli/dashboard_auth/login_page.py b/hermes_cli/dashboard_auth/login_page.py index 74da4dbe2..645944548 100644 --- a/hermes_cli/dashboard_auth/login_page.py +++ b/hermes_cli/dashboard_auth/login_page.py @@ -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 = """\ Public bind · Auth required +{password_script} """ @@ -350,6 +401,60 @@ auth gate (not recommended on untrusted networks).

""" +# 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 = """\ + +""" + + 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' ' - f'Sign in with {html.escape(p.display_name)}' - ) - 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' ' + f'Sign in with {html.escape(p.display_name)}' + ) + 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'
\n' + f'
Sign in with {plabel}
\n' + f' \n' + f' \n' + f' \n' + f' \n' + f' \n' + f'
' + ) diff --git a/hermes_cli/dashboard_auth/middleware.py b/hermes_cli/dashboard_auth/middleware.py index 8c6216e9e..0e25c1033 100644 --- a/hermes_cli/dashboard_auth/middleware.py +++ b/hermes_cli/dashboard_auth/middleware.py @@ -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", diff --git a/hermes_cli/dashboard_auth/routes.py b/hermes_cli/dashboard_auth/routes.py index 637cb7048..68ca1886c 100644 --- a/hermes_cli/dashboard_auth/routes.py +++ b/hermes_cli/dashboard_auth/routes.py @@ -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": }`` — + 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)