Add email/password auth: actor-based deps and /auth routes

- deps: every request resolves to an Actor — a signed-in user (session
  token, hash-stored) or a legacy env token with its historical
  semantics. require_admin/require_shared_write now accept admin users.
- /auth: status probe, first-run setup (first account = admin), login,
  logout, me, change-password, forgot/reset (one-shot links), and
  admin user management with invite links + a last-admin lockout guard.
- Invite/reset links are emailed when SMTP is configured and always
  returned to the admin; forgot never reveals account existence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR
This commit is contained in:
Claude
2026-08-12 19:16:27 +00:00
parent deb67c17cf
commit c227faa76d
4 changed files with 574 additions and 37 deletions
+2
View File
@@ -17,6 +17,7 @@ from ..config import get_settings
from .routes import (
agents,
approvals,
auth,
claude,
commands,
hosts,
@@ -44,6 +45,7 @@ def create_app() -> FastAPI:
def health() -> dict:
return {"status": "ok"}
app.include_router(auth.router)
app.include_router(projects.router)
app.include_router(agents.router)
app.include_router(interaction.router)
+121 -37
View File
@@ -1,24 +1,43 @@
"""Shared dependencies: bearer auth and a per-request DB connection.
"""Shared dependencies: bearer auth (user sessions + legacy env tokens) and a
per-request DB connection.
Auth is a single global token (README 3.3), compared in constant time. Shared-context
writes may require a separate higher-trust token (README 3.4), falling back to the
global token when unset.
Two kinds of callers hold a bearer token:
- **Users** — email + password accounts (``/auth``). Their bearer is an opaque session
token minted at login; the database stores only its hash. A user is either an admin
(sees and manages everything) or a regular account, which sees *shared* resources
(owner NULL) plus its own — the per-user separation of projects, skills, and tools.
- **Legacy env tokens** — ``AUTH_TOKEN`` / ``SHARED_CONTEXT_WRITE_TOKEN`` /
``ADMIN_TOKEN``, compared in constant time exactly as before user accounts existed.
They keep working for scripts/CI and as a break-glass credential, with their original
semantics: they see every resource, and the admin token passes the admin gates.
Every request resolves to one :class:`Actor`; route handlers consult it for ownership
decisions (``visible_scope`` / ``can_edit``).
"""
from __future__ import annotations
import secrets
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import Connection
from .. import authn
from ..config import Settings, get_settings
from ..db import repository as repo
from ..db.engine import connection
_bearer = HTTPBearer(auto_error=False)
# How stale a session's last_used_at may get before we write a fresh one (the dashboard
# polls every few seconds; a write per poll would be pure churn).
_TOUCH_INTERVAL = timedelta(minutes=5)
def db_conn() -> Iterator[Connection]:
with connection() as conn:
@@ -31,52 +50,117 @@ def _check(provided: str | None, expected: str) -> bool:
return secrets.compare_digest(provided, expected)
def require_auth(
@dataclass(frozen=True)
class Actor:
"""Who is making this request: a signed-in user or a legacy env token."""
kind: str # "user" | "token"
user_id: int | None = None
email: str | None = None
is_admin: bool = False
shared_write: bool = False # may write shared_context keys
@property
def sees_all(self) -> bool:
"""Admins and legacy tokens see every resource (tokens keep their historical
all-access semantics for scripts); regular users see shared + their own."""
return self.is_admin or self.kind == "token"
@property
def visible_scope(self):
"""The ``visible_to`` argument for repository list functions."""
return repo.VISIBLE_ALL if self.sees_all else self.user_id
@property
def label(self) -> str:
"""The ``requested_by`` audit label for commands this actor enqueues."""
if self.kind == "user":
return f"user:{self.user_id}:{self.email}"
return "operator:web"
def can_edit(self, owner_user_id: int | None) -> bool:
"""Mutation rule for owned resources: admins (and the legacy admin token) edit
anything; a user edits what they own. Shared rows (owner NULL) are admin-managed."""
if self.is_admin:
return True
if self.kind == "user":
return owner_user_id is not None and owner_user_id == self.user_id
return False
def can_view(self, owner_user_id: int | None) -> bool:
if self.sees_all:
return True
return owner_user_id is None or owner_user_id == self.user_id
def get_actor(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
settings: Settings = Depends(get_settings),
) -> None:
conn: Connection = Depends(db_conn),
) -> Actor:
"""Resolve the request's bearer to an :class:`Actor` or raise 401."""
token = creds.credentials if creds else None
# The shared-context write and admin tokens are higher-trust, so they also grant
# normal access; a single request carries one bearer, and it should never be rejected
# for being the more privileged one.
valid = (
_check(token, settings.auth_token)
or _check(token, settings.effective_shared_write_token)
or _check(token, settings.effective_admin_token)
if token is None:
raise _unauthorized()
# Legacy env tokens first (cheap constant-time compares). Order matters for the
# historical fallbacks: with ADMIN_TOKEN unset it falls back to AUTH_TOKEN, so the
# plain token must come out admin — checking the admin value first guarantees that.
if _check(token, settings.effective_admin_token):
return Actor(kind="token", is_admin=True, shared_write=True)
if _check(token, settings.effective_shared_write_token):
return Actor(kind="token", shared_write=True)
if _check(token, settings.auth_token):
return Actor(kind="token")
# Otherwise it may be a user session token (hash-stored).
token_hash = authn.hash_token(token)
row = repo.get_session_user(conn, token_hash)
if row is None:
raise _unauthorized()
last_used = row.get("session_last_used_at")
if last_used is None or datetime.now(UTC) - last_used > _TOUCH_INTERVAL:
repo.touch_auth_session(conn, token_hash)
return Actor(
kind="user",
user_id=row["id"],
email=row["email"],
is_admin=bool(row["is_admin"]),
shared_write=bool(row["is_admin"]),
)
if not valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid or missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
def require_shared_write(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
settings: Settings = Depends(get_settings),
) -> None:
"""Gate for shared_context writes — the one table every project implicitly trusts."""
token = creds.credentials if creds else None
if not _check(token, settings.effective_shared_write_token):
def _unauthorized() -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid or missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
def require_auth(actor: Actor = Depends(get_actor)) -> Actor:
return actor
def require_shared_write(actor: Actor = Depends(get_actor)) -> Actor:
"""Gate for shared_context writes — the one table every project implicitly trusts.
Admin users, the admin token, and the dedicated shared-write token qualify."""
if not actor.shared_write:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="shared-context write requires the shared-context write token",
detail="shared-context write requires the shared-context write token or an admin",
headers={"WWW-Authenticate": "Bearer"},
)
return actor
def require_admin(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
settings: Settings = Depends(get_settings),
) -> None:
"""Gate for the web control surface: enqueuing control commands, project/host CRUD,
and credential-pointer edits. Requires specifically the admin token (which defaults to
the global token when ADMIN_TOKEN is unset)."""
token = creds.credentials if creds else None
if not _check(token, settings.effective_admin_token):
def require_admin(actor: Actor = Depends(get_actor)) -> Actor:
"""Gate for the global control surface: git servers, the Claude account login,
permission overrides, and user management. Admin users or the admin token."""
if not actor.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="this action requires the admin token",
detail="this action requires an admin",
headers={"WWW-Authenticate": "Bearer"},
)
return actor
+341
View File
@@ -0,0 +1,341 @@
"""User accounts: sign-in, first-run setup, password resets, and admin user management.
The account model replaces "know the API key" for humans:
- **First run**: with zero accounts, ``POST /auth/setup`` creates the first one and it
is the admin. The UI probes ``GET /auth/status`` (unauthenticated, boolean-only) to
decide whether to show the setup form or the sign-in form.
- **Everyone after that** is created by an admin (``POST /auth/users``), which mints a
one-shot **invite link**; the invitee sets their own password through it. With SMTP
configured the link is emailed; either way it is returned to the admin.
- **Password reset**: self-serve ``POST /auth/forgot`` emails a short-lived reset link
(silent about whether the address exists); an admin can also mint a link directly
for any user. ``POST /auth/reset`` spends either kind of link.
Sessions are opaque bearer tokens (hash-stored, TTL from ``SESSION_TTL_DAYS``) used
exactly like the legacy env token — the client keeps calling with
``Authorization: Bearer …``. Legacy tokens stay valid for scripts/CI and break-glass.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import Connection
from ... import authn, emailer
from ...config import Settings, get_settings
from ...db import repository as repo
from ..deps import Actor, db_conn, get_actor, require_admin
from ..schemas import (
AuthStatusOut,
ChangePasswordIn,
ForgotIn,
ForgotOut,
LoginIn,
MeOut,
ResetIn,
ResetLinkOut,
SessionOut,
SetupIn,
UserCreatedOut,
UserCreateIn,
UserOut,
UserUpdateIn,
)
router = APIRouter(prefix="/auth", tags=["auth"])
# A throwaway hash so a login attempt against an unknown email costs the same scrypt
# work as one against a real account (no timing oracle on address existence).
_DUMMY_HASH = authn.hash_password("not-a-real-password")
def _user_out(row: dict) -> dict:
return {**row, "has_password": bool(row.get("password_hash"))}
def _new_session(conn: Connection, user: dict, settings: Settings) -> dict:
repo.purge_expired_sessions(conn) # piggybacked housekeeping — no scheduler needed
token = authn.new_token()
expires = datetime.now(UTC) + timedelta(days=settings.session_ttl_days)
repo.create_auth_session(conn, user["id"], authn.hash_token(token), expires)
return {"token": token, "user": _user_out(user)}
def _base_url(request: Request, settings: Settings) -> str:
base = settings.public_base_url.strip() or str(request.base_url)
return base.rstrip("/")
def _mint_link(
conn: Connection, request: Request, settings: Settings, user: dict, purpose: str
) -> str:
ttl_hours = (
settings.invite_token_ttl_hours if purpose == "invite" else settings.reset_token_ttl_hours
)
token = authn.new_token()
repo.create_auth_token(
conn,
user["id"],
authn.hash_token(token),
purpose,
datetime.now(UTC) + timedelta(hours=ttl_hours),
)
return f"{_base_url(request, settings)}/reset?token={token}"
def _try_email(user: dict, subject: str, body: str, settings: Settings) -> bool:
if not emailer.configured(settings):
return False
try:
emailer.send(user["email"], subject, body, settings)
return True
except emailer.EmailError:
# The link is still returned/usable; delivery failure must not lose it.
return False
# ---- public (unauthenticated) ----------------------------------------------------------
@router.get("/status", response_model=AuthStatusOut)
def auth_status(conn: Connection = Depends(db_conn)) -> dict:
return {
"initialized": repo.count_users(conn) > 0,
"smtp_configured": emailer.configured(get_settings()),
}
@router.post("/setup", response_model=SessionOut, status_code=status.HTTP_201_CREATED)
def setup(body: SetupIn, conn: Connection = Depends(db_conn)) -> dict:
"""Create the first account — the admin. Refused once any account exists."""
if repo.count_users(conn) > 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="already set up — sign in, or ask an admin to invite you",
)
email = body.email.strip().lower()
if "@" not in email:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="invalid email address")
user = repo.create_user(
conn, email, password_hash=authn.hash_password(body.password), is_admin=True
)
return _new_session(conn, user, get_settings())
@router.post("/login", response_model=SessionOut)
def login(body: LoginIn, conn: Connection = Depends(db_conn)) -> dict:
user = repo.get_user_by_email(conn, body.email)
stored = user["password_hash"] if user else _DUMMY_HASH
if not authn.verify_password(body.password, stored) or user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="invalid email or password")
if user["disabled"]:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="this account is disabled")
return _new_session(conn, user, get_settings())
@router.post("/forgot", response_model=ForgotOut)
def forgot(
body: ForgotIn, request: Request, conn: Connection = Depends(db_conn)
) -> dict:
"""Self-serve reset. Always answers ``ok`` — whether the address has an account is
not revealed. Without SMTP nothing can be sent; the UI tells the user to ask an
admin for a reset link instead."""
settings = get_settings()
if not emailer.configured(settings):
return {"ok": True, "emailed": False}
user = repo.get_user_by_email(conn, body.email)
if user is not None and not user["disabled"] and user.get("password_hash"):
link = _mint_link(conn, request, settings, user, "reset")
_try_email(
user,
"Reset your Handler password",
"A password reset was requested for this address.\n\n"
f"Reset it here (link expires in {settings.reset_token_ttl_hours}h):\n{link}\n\n"
"If you didn't request this, you can ignore this email.",
settings,
)
return {"ok": True, "emailed": True}
@router.post("/reset", response_model=SessionOut)
def reset(body: ResetIn, conn: Connection = Depends(db_conn)) -> dict:
"""Spend a reset/invite link: set the password and sign the user in. Every other
session for the account is revoked — a reset means the old credential is suspect."""
token_row = repo.consume_auth_token(conn, authn.hash_token(body.token))
if token_row is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="this link is invalid, expired, or already used — request a new one",
)
user = repo.get_user(conn, token_row["user_id"])
if user is None or user["disabled"]:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="this account is disabled")
user = repo.update_user(
conn, user["id"], password_hash=authn.hash_password(body.password)
)
repo.delete_user_sessions(conn, user["id"])
return _new_session(conn, user, get_settings())
# ---- authenticated self-service --------------------------------------------------------
@router.get("/me", response_model=MeOut)
def me(actor: Actor = Depends(get_actor)) -> dict:
return {
"kind": actor.kind,
"user_id": actor.user_id,
"email": actor.email,
"is_admin": actor.is_admin,
}
@router.post("/logout")
def logout(
request: Request,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
"""Revoke the presented session token. A no-op for legacy env tokens (they are
configuration, not sessions)."""
if actor.kind == "user":
auth_header = request.headers.get("authorization", "")
token = auth_header.split(" ", 1)[1] if " " in auth_header else ""
repo.delete_auth_session(conn, authn.hash_token(token))
return {"ok": True}
@router.post("/change-password")
def change_password(
body: ChangePasswordIn,
request: Request,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
if actor.kind != "user":
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="env tokens have no password to change"
)
user = repo.get_user(conn, actor.user_id)
if user is None or not authn.verify_password(body.current_password, user["password_hash"]):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="current password is incorrect")
repo.update_user(conn, user["id"], password_hash=authn.hash_password(body.new_password))
# Sign out every *other* session; the one making this change keeps working.
auth_header = request.headers.get("authorization", "")
token = auth_header.split(" ", 1)[1] if " " in auth_header else ""
repo.delete_user_sessions(conn, user["id"], keep_token_hash=authn.hash_token(token))
return {"ok": True}
# ---- admin user management -------------------------------------------------------------
def _target_or_404(conn: Connection, user_id: int) -> dict:
user = repo.get_user(conn, user_id)
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"user {user_id} not found")
return user
def _guard_last_admin(conn: Connection, target: dict, detail: str) -> None:
"""Refuse a change that would leave zero sign-in-capable admins (lockout guard).
Only matters when the target currently counts as an active admin."""
if (
target["is_admin"]
and not target["disabled"]
and target.get("password_hash")
and repo.count_active_admins(conn, exclude_user_id=target["id"]) == 0
):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=detail)
@router.get("/users", response_model=list[UserOut], dependencies=[Depends(require_admin)])
def list_users(conn: Connection = Depends(db_conn)) -> list[dict]:
return [_user_out(u) for u in repo.list_users(conn)]
@router.post(
"/users",
response_model=UserCreatedOut,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
)
def create_user(
body: UserCreateIn, request: Request, conn: Connection = Depends(db_conn)
) -> dict:
settings = get_settings()
email = body.email.strip().lower()
if "@" not in email:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="invalid email address")
if repo.get_user_by_email(conn, email) is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"'{email}' already has an account")
user = repo.create_user(conn, email, password_hash=None, is_admin=body.is_admin)
link = _mint_link(conn, request, settings, user, "invite")
emailed = _try_email(
user,
"You've been invited to Handler",
"An admin created a Handler account for this address.\n\n"
f"Set your password here (link expires in {settings.invite_token_ttl_hours // 24} "
f"days):\n{link}\n",
settings,
)
return {"user": _user_out(user), "invite_url": link, "emailed": emailed}
@router.patch(
"/users/{user_id}", response_model=UserOut, dependencies=[Depends(require_admin)]
)
def update_user(
user_id: int, body: UserUpdateIn, conn: Connection = Depends(db_conn)
) -> dict:
target = _target_or_404(conn, user_id)
fields = body.model_dump(exclude_unset=True)
if fields.get("is_admin") is False or fields.get("disabled") is True:
_guard_last_admin(
conn, target, "refused: this is the last active admin — promote someone else first"
)
return _user_out(repo.update_user(conn, user_id, **fields))
@router.delete("/users/{user_id}", dependencies=[Depends(require_admin)])
def delete_user(
user_id: int,
actor: Actor = Depends(require_admin),
conn: Connection = Depends(db_conn),
) -> dict:
target = _target_or_404(conn, user_id)
if actor.kind == "user" and actor.user_id == user_id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="you can't delete your own account"
)
_guard_last_admin(
conn, target, "refused: this is the last active admin — promote someone else first"
)
repo.delete_user(conn, user_id)
return {"deleted": target["email"], "note": "their projects/skills/tools became shared"}
@router.post(
"/users/{user_id}/reset-link",
response_model=ResetLinkOut,
dependencies=[Depends(require_admin)],
)
def mint_reset_link(
user_id: int, request: Request, conn: Connection = Depends(db_conn)
) -> dict:
"""Admin-minted reset link — the escape hatch when SMTP is off (or the email never
arrived). Uses invite semantics (longer TTL) for accounts that never set a password."""
settings = get_settings()
target = _target_or_404(conn, user_id)
purpose = "invite" if not target.get("password_hash") else "reset"
link = _mint_link(conn, request, settings, target, purpose)
emailed = _try_email(
target,
"Reset your Handler password",
f"An admin generated a password {purpose} link for your account:\n{link}\n",
settings,
)
return {"reset_url": link, "emailed": emailed}
+110
View File
@@ -104,6 +104,8 @@ class ProjectOut(BaseModel):
root_dir: str
git_remote: str | None = None
credential_ref: str | None = None
# Owning user account; null = shared/legacy (visible to everyone, admin-managed).
owner_user_id: int | None = None
created_at: datetime
@@ -409,6 +411,7 @@ class ClaudeSkillOut(BaseModel):
# Relative paths of auxiliary files (references/, scripts/, …) captured by the
# install-from-prompt import; synced alongside SKILL.md, read-only over the API.
files: list[str] = Field(default_factory=list)
owner_user_id: int | None = None
created_at: datetime
updated_at: datetime
@@ -472,6 +475,7 @@ class ClaudeConnectorOut(BaseModel):
url: str | None = None
headers: dict[str, str] | None = None
enabled: bool
owner_user_id: int | None = None
created_at: datetime
@@ -521,6 +525,7 @@ class ClaudePluginOut(BaseModel):
marketplace: str
marketplace_repo: str
enabled: bool
owner_user_id: int | None = None
created_at: datetime
@@ -587,6 +592,7 @@ class ClaudeModelOut(BaseModel):
enabled: bool
# The key never leaves the server; this says whether one is stored.
has_api_key: bool = False
owner_user_id: int | None = None
created_at: datetime
@@ -680,3 +686,107 @@ class MemoryGraphOut(BaseModel):
notes: list[MemoryNoteOut]
links: list[MemoryLinkOut]
# ---- user accounts & sessions (``/auth``) ----------------------------------------------
class AuthStatusOut(BaseModel):
"""Unauthenticated bootstrap probe: does the login page show a sign-in form or the
first-run setup form, and can the deployment send email?"""
initialized: bool # any user account exists
smtp_configured: bool
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: str
is_admin: bool
disabled: bool
# False until an invited user sets their password through the invite link.
has_password: bool = False
created_at: datetime
class SetupIn(BaseModel):
"""First-run: create the very first account, which becomes the admin."""
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=8, max_length=1024)
class LoginIn(BaseModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=1024)
class SessionOut(BaseModel):
"""A fresh bearer session token plus who it belongs to."""
token: str
user: UserOut
class MeOut(BaseModel):
"""Who the presented bearer resolves to. Legacy env tokens have no user identity —
``kind == "token"`` with null user fields."""
kind: str # "user" | "token"
user_id: int | None = None
email: str | None = None
is_admin: bool
class ChangePasswordIn(BaseModel):
current_password: str = Field(min_length=1, max_length=1024)
new_password: str = Field(min_length=8, max_length=1024)
class ForgotIn(BaseModel):
email: str = Field(min_length=3, max_length=254)
class ForgotOut(BaseModel):
"""Always ``ok`` — whether the address has an account is deliberately not revealed."""
ok: bool = True
emailed: bool # False when SMTP is not configured (ask an admin for a reset link)
class ResetIn(BaseModel):
"""Complete a reset or invite link: spend the one-shot token, set the password."""
token: str = Field(min_length=1, max_length=256)
password: str = Field(min_length=8, max_length=1024)
class UserCreateIn(BaseModel):
"""Admin creates an account; the new user sets their password via the invite link."""
email: str = Field(min_length=3, max_length=254)
is_admin: bool = False
class UserCreatedOut(BaseModel):
user: UserOut
# The invite link is always returned (the admin can hand it over out-of-band);
# ``emailed`` says whether it was also delivered by SMTP.
invite_url: str
emailed: bool
class UserUpdateIn(BaseModel):
"""Admin edits; omit a field to leave it unchanged."""
is_admin: bool | None = None
disabled: bool | None = None
class ResetLinkOut(BaseModel):
"""An admin-minted reset (or invite) link for a user."""
reset_url: str
emailed: bool