From c8280f7078cb4f30ab9ff9fa8107aeb08bad19d5 Mon Sep 17 00:00:00 2001 From: Jonas Miederer Date: Fri, 11 Sep 2026 17:27:58 +0200 Subject: [PATCH] feat: magic-link auth with access requests and RBAC --- backend/app/auth/__init__.py | 1 + backend/app/auth/api.py | 173 +++++++++++ backend/app/auth/deps.py | 57 ++++ backend/app/auth/mail.py | 66 +++++ backend/app/auth/schemas.py | 49 ++++ backend/app/auth/service.py | 277 ++++++++++++++++++ backend/app/flows/api.py | 17 ++ backend/app/main.py | 3 + backend/app/models/__init__.py | 12 +- backend/app/models/user.py | 72 +++++ backend/app/settings.py | 23 +- .../versions/a3f1c9e2b4d7_auth_tables.py | 82 ++++++ backend/tests/test_auth.py | 228 ++++++++++++++ 13 files changed, 1058 insertions(+), 2 deletions(-) create mode 100644 backend/app/auth/__init__.py create mode 100644 backend/app/auth/api.py create mode 100644 backend/app/auth/deps.py create mode 100644 backend/app/auth/mail.py create mode 100644 backend/app/auth/schemas.py create mode 100644 backend/app/auth/service.py create mode 100644 backend/app/models/user.py create mode 100644 backend/migrations/versions/a3f1c9e2b4d7_auth_tables.py create mode 100644 backend/tests/test_auth.py diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..0466d57 --- /dev/null +++ b/backend/app/auth/__init__.py @@ -0,0 +1 @@ +"""Magic-link authentication + access-request approval (SMTP invite).""" diff --git a/backend/app/auth/api.py b/backend/app/auth/api.py new file mode 100644 index 0000000..153668b --- /dev/null +++ b/backend/app/auth/api.py @@ -0,0 +1,173 @@ +"""Access + auth HTTP API (magic-link login, session cookie).""" + +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from sqlmodel import Session + +from app.models import User + +from . import mail, service +from .deps import ( + SESSION_COOKIE, + SESSION_MAX_AGE, + get_current_user, + get_db, + require_admin, +) +from .schemas import ( + AccessDecisionRead, + AccessRequestCreate, + AccessRequestRead, + AdminBootstrapRequest, + MagicConsumeRequest, + MeRead, + MessageRead, +) + +access_router = APIRouter(prefix="/api/access", tags=["access"]) +auth_router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +def _read_request(r) -> AccessRequestRead: + return AccessRequestRead( + id=r.id, + name=r.name, + email=r.email, + reason=r.reason, + status=r.status, + created_at=r.created_at, + decided_at=r.decided_at, + decided_by=r.decided_by, + ) + + +def _read_me(user: User) -> MeRead: + return MeRead( + id=user.id, + email=user.email, + name=user.name, + role=user.role, + tenant_id=user.tenant_id, + ) + + +def _set_session_cookie(response: Response, session_id: str) -> None: + # localhost is a secure context, so Secure is safe for local dev too. + response.set_cookie( + SESSION_COOKIE, + session_id, + max_age=SESSION_MAX_AGE, + path="/", + httponly=True, + secure=True, + samesite="lax", + ) + + +@access_router.post("/requests", response_model=AccessRequestRead, status_code=201) +def create_request(data: AccessRequestCreate, session: Session = Depends(get_db)): + try: + request = service.create_access_request( + session, data.name, data.email, data.reason + ) + except service.RateLimitedError as exc: + raise HTTPException(status_code=429, detail=str(exc)) from exc + mail.send_access_notification(str(request.id), request.name, request.email) + return _read_request(request) + + +@access_router.get("/requests", response_model=list[AccessRequestRead]) +def list_requests( + status: str | None = Query(default=None), + session: Session = Depends(get_db), + admin: User = Depends(require_admin), +): + return [_read_request(r) for r in service.list_access_requests(session, status)] + + +@access_router.post("/requests/{request_id}/approve", response_model=AccessDecisionRead) +def approve_request( + request_id: UUID, + session: Session = Depends(get_db), + admin: User = Depends(require_admin), +): + try: + request, _user, token = service.approve_access_request( + session, request_id, admin.email + ) + except service.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except service.ConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + # The token travels by mail only — never in an API response. + email_sent = mail.send_magic_link(request.email, mail.magic_link(token)) + return AccessDecisionRead(request=_read_request(request), email_sent=email_sent) + + +@access_router.post("/requests/{request_id}/reject", response_model=AccessRequestRead) +def reject_request( + request_id: UUID, + session: Session = Depends(get_db), + admin: User = Depends(require_admin), +): + try: + request = service.reject_access_request(session, request_id, admin.email) + except service.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except service.ConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return _read_request(request) + + +@auth_router.post("/magic/consume", response_model=MeRead) +def consume_magic( + data: MagicConsumeRequest, + response: Response, + session: Session = Depends(get_db), +): + try: + user_session = service.consume_magic_token(session, data.token) + except ( + service.InvalidTokenError, + service.ExpiredTokenError, + service.UsedTokenError, + ) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + user = service.get_user(session, user_session.user_id) + if user is None: # pragma: no cover - consume already validated the user + raise HTTPException(status_code=400, detail="unknown token") + _set_session_cookie(response, user_session.id) + return _read_me(user) + + +@auth_router.get("/me", response_model=MeRead) +def read_me(user: User = Depends(get_current_user)): + return _read_me(user) + + +@auth_router.post("/logout", response_model=MessageRead) +def logout( + request: Request, + response: Response, + session: Session = Depends(get_db), +): + # Revoke the server-side row when the cookie maps to a live session; + # always clear the client cookie. + session_id = request.cookies.get(SESSION_COOKIE) + if session_id: + service.revoke_session(session, session_id) + response.delete_cookie(SESSION_COOKIE, path="/") + return MessageRead(message="logged out") + + +@auth_router.post("/admin-bootstrap", response_model=MessageRead, status_code=201) +def admin_bootstrap(data: AdminBootstrapRequest, session: Session = Depends(get_db)): + try: + _user, token = service.bootstrap_admin(session, data.email) + except service.ConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except service.ForbiddenError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + mail.send_magic_link(service.normalize_email(data.email), mail.magic_link(token)) + return MessageRead(message="admin created, magic link sent") diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py new file mode 100644 index 0000000..bdaa2ce --- /dev/null +++ b/backend/app/auth/deps.py @@ -0,0 +1,57 @@ +"""Session-cookie dependencies. Handlers stay thin; DB via get_session.""" + +from __future__ import annotations + +from collections.abc import Iterator + +from fastapi import Depends, HTTPException, Request +from sqlmodel import Session + +from app.db import get_session +from app.models import User + +from . import service + +SESSION_COOKIE = "kirche_session" +SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds()) + + +def get_db() -> Iterator[Session]: + yield from get_session() + + +def get_optional_user( + request: Request, session: Session = Depends(get_db) +) -> User | None: + session_id = request.cookies.get(SESSION_COOKIE) + if not session_id: + return None + record = service.get_valid_session(session, session_id) + if record is None: + return None + return service.get_user(session, record.user_id) + + +def get_current_user( + request: Request, session: Session = Depends(get_db) +) -> User: + user = get_optional_user(request, session) + if user is None: + raise HTTPException(status_code=401, detail="not authenticated") + return user + + +def require_admin(user: User = Depends(get_current_user)) -> User: + if user.role != "admin": + raise HTTPException(status_code=403, detail="admin only") + return user + + +def resolve_tenant( + user: User | None, explicit: str | None, default: str = "default" +) -> str: + """Tenant comes from the session; the body/query param is a deprecated + fallback for unauthenticated callers.""" + if user is not None: + return user.tenant_id + return explicit or default diff --git a/backend/app/auth/mail.py b/backend/app/auth/mail.py new file mode 100644 index 0000000..c8ca35b --- /dev/null +++ b/backend/app/auth/mail.py @@ -0,0 +1,66 @@ +"""Magic-link delivery. SMTP when configured, dev log fallback otherwise.""" + +from __future__ import annotations + +import logging +import smtplib +from email.message import EmailMessage + +from app.settings import settings + +logger = logging.getLogger(__name__) + + +def magic_link(token: str) -> str: + return f"{settings.frontend_url.rstrip('/')}/login?token={token}" + + +def send_magic_link(to_email: str, link: str) -> bool: + """Mail the login link. Returns False (logs instead) when SMTP is unset. + + The plaintext link only ever appears here: service/api logging must + never include tokens or session ids (see test_auth no-leak test). + """ + if not settings.smtp_host: + logger.info("SMTP not configured; magic link for %s: %s", to_email, link) + return False + message = EmailMessage() + message["Subject"] = "Your kirche-einfach login link" + message["To"] = to_email + message["From"] = settings.mail_from or settings.contact_to or to_email + if settings.contact_to: + message["Reply-To"] = settings.contact_to + message.set_content( + f"Hello,\n\nUse this link to log in (valid 15 minutes, single use):\n{link}\n" + ) + with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp: + smtp.starttls() + if settings.smtp_username: + smtp.login(settings.smtp_username, settings.smtp_password) + smtp.send_message(message) + logger.info("magic link mailed to %s", to_email) + return True + + +def send_access_notification(request_id: str, name: str, email: str) -> bool: + """Notify CONTACT_TO about a new access request (best effort).""" + if not settings.smtp_host or not settings.contact_to: + logger.info("new access request %s from %s <%s>", request_id, name, email) + return False + message = EmailMessage() + message["Subject"] = f"New access request: {name}" + message["To"] = settings.contact_to + message["From"] = settings.mail_from or settings.contact_to + message.set_content( + f"Name: {name}\nEmail: {email}\nRequest: {request_id}\n" + ) + try: + with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp: + smtp.starttls() + if settings.smtp_username: + smtp.login(settings.smtp_username, settings.smtp_password) + smtp.send_message(message) + except OSError: + logger.warning("access-request notification failed for %s", request_id) + return False + return True diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py new file mode 100644 index 0000000..cee9e35 --- /dev/null +++ b/backend/app/auth/schemas.py @@ -0,0 +1,49 @@ +"""Pydantic contracts for the access/auth API.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + + +class AccessRequestCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + email: str = Field(min_length=3, max_length=320) + reason: str = Field(default="", max_length=2000) + + +class AccessRequestRead(BaseModel): + id: UUID + name: str + email: str + reason: str + status: str + created_at: datetime + decided_at: datetime | None + decided_by: str | None + + +class AccessDecisionRead(BaseModel): + request: AccessRequestRead + # False when SMTP is unset (dev): the magic link is logged, not mailed. + email_sent: bool + + +class MagicConsumeRequest(BaseModel): + token: str = Field(min_length=1, max_length=200) + + +class MeRead(BaseModel): + id: UUID + email: str + name: str + role: str + tenant_id: str + + +class AdminBootstrapRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + + +class MessageRead(BaseModel): + message: str diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py new file mode 100644 index 0000000..bf13261 --- /dev/null +++ b/backend/app/auth/service.py @@ -0,0 +1,277 @@ +"""Access requests, magic-link tokens, and sessions (DB via function args). + +Keep API handlers thin: all persistence lives here. Tokens and session ids +are bearer secrets — log user/request ids only, never secret values. +""" + +from __future__ import annotations + +import hashlib +import logging +import secrets +from datetime import datetime, timedelta, timezone +from uuid import UUID + +from sqlmodel import Session, func, select + +from app.models import AccessRequest, MagicLinkToken, User, UserSession +from app.settings import settings + +logger = logging.getLogger(__name__) + +MAGIC_LINK_TTL = timedelta(minutes=15) +SESSION_TTL = timedelta(days=30) + +# Public request endpoint: at most this many requests per email per hour. +REQUESTS_PER_EMAIL_PER_HOUR = 5 + + +class AuthError(Exception): + pass + + +class NotFoundError(AuthError): + pass + + +class ConflictError(AuthError): + pass + + +class ForbiddenError(AuthError): + pass + + +class InvalidTokenError(AuthError): + pass + + +class ExpiredTokenError(AuthError): + pass + + +class UsedTokenError(AuthError): + pass + + +class RateLimitedError(AuthError): + pass + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def normalize_email(email: str) -> str: + return email.strip().lower() + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def is_admin_email(email: str) -> bool: + return normalize_email(email) in settings.admin_email_list + + +def role_for(email: str) -> str: + return "admin" if is_admin_email(email) else "member" + + +def get_user(session: Session, user_id: UUID) -> User | None: + return session.get(User, user_id) + + +def get_user_by_email(session: Session, email: str) -> User | None: + return session.exec( + select(User).where(User.email == normalize_email(email)) + ).first() + + +def user_count(session: Session) -> int: + return session.exec(select(func.count()).select_from(User)).one() + + +def recent_request_count(session: Session, email: str, hours: int = 1) -> int: + since = utcnow() - timedelta(hours=hours) + return session.exec( + select(func.count()) + .select_from(AccessRequest) + .where( + AccessRequest.email == normalize_email(email), + AccessRequest.created_at >= since, + ) + ).one() + + +def create_access_request( + session: Session, name: str, email: str, reason: str = "" +) -> AccessRequest: + email = normalize_email(email) + if recent_request_count(session, email) >= REQUESTS_PER_EMAIL_PER_HOUR: + raise RateLimitedError("too many requests from this email, try again later") + request = AccessRequest(name=name.strip(), email=email, reason=reason.strip()) + session.add(request) + session.commit() + session.refresh(request) + logger.info("access request %s from %s", request.id, email) + return request + + +def get_access_request(session: Session, request_id: UUID) -> AccessRequest: + request = session.get(AccessRequest, request_id) + if request is None: + raise NotFoundError(f"access request {request_id} not found") + return request + + +def list_access_requests(session: Session, status: str | None = None) -> list[AccessRequest]: + query = select(AccessRequest).order_by(AccessRequest.created_at.desc()) + if status is not None: + query = query.where(AccessRequest.status == status) + return list(session.exec(query).all()) + + +def _get_or_create_user(session: Session, name: str, email: str) -> User: + user = get_user_by_email(session, email) + if user is None: + user = User(name=name.strip(), email=email, role=role_for(email)) + session.add(user) + session.flush() + logger.info("user %s created for %s", user.id, email) + else: + if not user.name and name.strip(): + user.name = name.strip() + new_role = role_for(email) + if user.role != new_role: + user.role = new_role + session.add(user) + session.flush() + return user + + +def issue_magic_token(session: Session, user: User) -> str: + token = secrets.token_urlsafe(32) + session.add( + MagicLinkToken( + user_id=user.id, + token_hash=hash_token(token), + expires_at=utcnow() + MAGIC_LINK_TTL, + ) + ) + session.commit() + logger.info("magic token issued for user %s", user.id) + return token + + +def approve_access_request( + session: Session, request_id: UUID, decided_by: str +) -> tuple[AccessRequest, User, str]: + """Approve → upsert User + fresh magic-link token (plaintext, mail it).""" + request = get_access_request(session, request_id) + if request.status != "pending": + raise ConflictError(f"access request {request_id} already {request.status}") + user = _get_or_create_user(session, request.name, request.email) + request.status = "approved" + request.decided_at = utcnow() + request.decided_by = normalize_email(decided_by) + session.add(request) + session.commit() + session.refresh(request) + token = issue_magic_token(session, user) + logger.info( + "access request %s approved by %s; user %s", + request.id, + request.decided_by, + user.id, + ) + return request, user, token + + +def reject_access_request( + session: Session, request_id: UUID, decided_by: str +) -> AccessRequest: + request = get_access_request(session, request_id) + if request.status != "pending": + raise ConflictError(f"access request {request_id} already {request.status}") + request.status = "rejected" + request.decided_at = utcnow() + request.decided_by = normalize_email(decided_by) + session.add(request) + session.commit() + session.refresh(request) + logger.info("access request %s rejected by %s", request.id, request.decided_by) + return request + + +def _aware(value: datetime) -> datetime: + # SQLite returns naive datetimes; stored values are UTC. + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + + +def consume_magic_token(session: Session, token: str) -> UserSession: + """Single-use consume → session row. used_at is set before creation.""" + record = session.exec( + select(MagicLinkToken).where( + MagicLinkToken.token_hash == hash_token(token.strip()) + ) + ).first() + if record is None: + raise InvalidTokenError("unknown token") + if record.used_at is not None: + raise UsedTokenError("token already used") + if _aware(record.expires_at) <= utcnow(): + raise ExpiredTokenError("token expired") + user = session.get(User, record.user_id) + if user is None: + raise InvalidTokenError("unknown token") + # Mark used first: a reused token can never mint a second session. + record.used_at = utcnow() + session.add(record) + user_session = UserSession( + id=secrets.token_urlsafe(32), + user_id=user.id, + expires_at=utcnow() + SESSION_TTL, + ) + session.add(user_session) + session.commit() + session.refresh(user_session) + logger.info("magic token consumed for user %s", user.id) + return user_session + + +def get_valid_session(session: Session, session_id: str) -> UserSession | None: + record = session.get(UserSession, session_id) + if record is None or record.revoked_at is not None: + return None + if _aware(record.expires_at) <= utcnow(): + return None + return record + + +def revoke_session(session: Session, session_id: str) -> None: + record = session.get(UserSession, session_id) + if record is not None and record.revoked_at is None: + record.revoked_at = utcnow() + session.add(record) + session.commit() + + +def bootstrap_admin(session: Session, email: str) -> tuple[User, str]: + """First-admin setup: only when the user table is empty AND the email + is in ADMIN_EMAILS. Issues a magic-link token like approve does.""" + email = normalize_email(email) + if user_count(session) > 0: + raise ConflictError("users already exist, bootstrap is closed") + if not is_admin_email(email): + raise ForbiddenError("email is not an admin email") + user = User(name="", email=email, role="admin") + session.add(user) + session.flush() + session.commit() + session.refresh(user) + token = issue_magic_token(session, user) + logger.info("admin bootstrapped for user %s", user.id) + return user, token diff --git a/backend/app/flows/api.py b/backend/app/flows/api.py index 6d4aaa9..0a4dd0b 100644 --- a/backend/app/flows/api.py +++ b/backend/app/flows/api.py @@ -13,7 +13,9 @@ from fastapi.responses import StreamingResponse from sqlmodel import Session from app.db import get_engine, get_session +from app.models import User from app.settings import settings +from app.auth.deps import get_optional_user, resolve_tenant from . import runner, store from .deploy import import_from_langflow @@ -225,7 +227,11 @@ async def run_flow( stream: bool = Query(default=False), session: Session = Depends(get_db), lfx: LfxClient = Depends(get_lfx), + user: User | None = Depends(get_optional_user), ): + # Tenant comes from the session; the body param stays as a deprecated + # fallback for unauthenticated callers. + data = data.model_copy(update={"tenant_id": resolve_tenant(user, data.tenant_id)}) try: version = _resolve_version(session, flow_id, data.version_no) except store.NotFoundError as exc: @@ -316,7 +322,12 @@ def list_runs( flow_id: UUID, tenant_id: str | None = Query(default=None), session: Session = Depends(get_db), + user: User | None = Depends(get_optional_user), ): + # Authenticated callers are scoped to their session tenant unless they + # pass an explicit tenant (deprecated fallback for unauthenticated use). + if user is not None and tenant_id is None: + tenant_id = user.tenant_id try: return [_read_run(r) for r in store.list_runs(session, flow_id, tenant_id)] except store.NotFoundError as exc: @@ -380,8 +391,11 @@ def get_run( run_id: UUID, tenant_id: str | None = Query(default=None), session: Session = Depends(get_db), + user: User | None = Depends(get_optional_user), ): """Read a run. With tenant_id, cross-tenant reads 404 (v1 scoping).""" + if user is not None and tenant_id is None: + tenant_id = user.tenant_id try: run = store.get_run(session, run_id) except store.NotFoundError as exc: @@ -408,7 +422,10 @@ def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)): def list_approvals( tenant_id: str | None = Query(default=None), session: Session = Depends(get_db), + user: User | None = Depends(get_optional_user), ): + if user is not None and tenant_id is None: + tenant_id = user.tenant_id return [_read_task(t) for t in store.list_approval_tasks(session, tenant_id)] diff --git a/backend/app/main.py b/backend/app/main.py index 94c67d6..1797b1b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,6 +7,7 @@ from pathlib import Path from fastapi import FastAPI from .flows.api import approvals_router, flows_router +from .auth.api import access_router, auth_router from .flows.sync import run_sync_blocking, watch_forever from .settings import settings @@ -65,6 +66,8 @@ app = FastAPI( app.include_router(flows_router) app.include_router(approvals_router) +app.include_router(access_router) +app.include_router(auth_router) @app.get("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6ff89f5..42dcc82 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,3 +1,13 @@ from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion +from .user import AccessRequest, MagicLinkToken, User, UserSession -__all__ = ["ApprovalTask", "FlowDefinition", "FlowRun", "FlowVersion"] +__all__ = [ + "AccessRequest", + "ApprovalTask", + "FlowDefinition", + "FlowRun", + "FlowVersion", + "MagicLinkToken", + "User", + "UserSession", +] diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..89f0b7c --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,72 @@ +"""Access-management domain models (SQLModel tables).""" + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from sqlalchemy import Column, DateTime +from sqlmodel import Field, SQLModel + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _ts_column() -> Column: + return Column(DateTime(timezone=True)) + + +class User(SQLModel, table=True): + """A person with API access. Role is member or admin.""" + + __tablename__ = "users" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + email: str = Field(index=True, unique=True, max_length=320) + name: str = Field(default="", max_length=200) + # member | admin + role: str = Field(default="member", max_length=20) + tenant_id: str = Field(default="default", max_length=200) + created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + + +class AccessRequest(SQLModel, table=True): + """A public request for access; an admin approves or rejects it.""" + + __tablename__ = "access_requests" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str = Field(max_length=200) + email: str = Field(index=True, max_length=320) + reason: str = Field(default="", max_length=2000) + # pending | approved | rejected + status: str = Field(default="pending", index=True, max_length=20) + created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + decided_at: datetime | None = Field(default=None, sa_column=_ts_column()) + decided_by: str | None = Field(default=None, max_length=320) + + +class MagicLinkToken(SQLModel, table=True): + """A single-use 15-minute login token. Only the sha256 is stored.""" + + __tablename__ = "magic_link_tokens" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + user_id: UUID = Field(foreign_key="users.id", index=True) + token_hash: str = Field(index=True, unique=True, max_length=64) + expires_at: datetime = Field(sa_column=_ts_column()) + # Set before the session row is created, so a reused token can never + # mint a second session. + used_at: datetime | None = Field(default=None, sa_column=_ts_column()) + created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + + +class UserSession(SQLModel, table=True): + """An opaque session id in the DB (cookie value). Revocable, expirable.""" + + __tablename__ = "user_sessions" + + id: str = Field(primary_key=True, max_length=128) + user_id: UUID = Field(foreign_key="users.id", index=True) + created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + expires_at: datetime = Field(sa_column=_ts_column()) + revoked_at: datetime | None = Field(default=None, sa_column=_ts_column()) diff --git a/backend/app/settings.py b/backend/app/settings.py index 5c59dea..173a329 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -14,7 +14,9 @@ class Settings(BaseSettings): debug: bool = False # App database (SQLite file). Override via DATABASE_URL for other engines. - database_url: str = str(REPO_ROOT / "backend" / "data" / "kirche.db") + # Must be a URL (not a bare path): db.py and Alembic hand it to + # create_engine, which rejects bare paths. + database_url: str = f"sqlite:///{REPO_ROOT / 'backend' / 'data' / 'kirche.db'}" # Canonical flow definitions (source of truth): one Langflow export per file. flows_dir: str = str(REPO_ROOT / "backend" / "flows") @@ -30,5 +32,24 @@ class Settings(BaseSettings): # FROM the LFX process (compose: http://api:8000). callback_base_url: str = "http://localhost:8000" + # --- access management (magic-link auth, see app/auth) --- + # Public frontend URL (magic links point at /login?token= there). + frontend_url: str = "http://localhost:3000" + # Comma-separated emails that become admins (role assignment + bootstrap). + admin_emails: str = "" + # Reply-To on outgoing mail; also receives access-request notifications. + contact_to: str = "" + mail_from: str = "" + smtp_host: str = "" + smtp_port: int = 587 + smtp_username: str = "" + smtp_password: str = "" + + @property + def admin_email_list(self) -> list[str]: + return [ + e.strip().lower() for e in self.admin_emails.split(",") if e.strip() + ] + settings = Settings() diff --git a/backend/migrations/versions/a3f1c9e2b4d7_auth_tables.py b/backend/migrations/versions/a3f1c9e2b4d7_auth_tables.py new file mode 100644 index 0000000..71ef631 --- /dev/null +++ b/backend/migrations/versions/a3f1c9e2b4d7_auth_tables.py @@ -0,0 +1,82 @@ +"""auth tables (users, access requests, magic links, sessions) + +Revision ID: a3f1c9e2b4d7 +Revises: d932b6993f9a +Create Date: 2026-09-11 15:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types + + +# revision identifiers, used by Alembic. +revision: str = 'a3f1c9e2b4d7' +down_revision: Union[str, Sequence[str], None] = 'd932b6993f9a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table('users', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False), + sa.Column('role', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False), + sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('access_requests', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False), + sa.Column('reason', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('decided_by', sqlmodel.sql.sqltypes.AutoString(length=320), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_access_requests_email'), 'access_requests', ['email'], unique=False) + op.create_index(op.f('ix_access_requests_status'), 'access_requests', ['status'], unique=False) + op.create_table('magic_link_tokens', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('token_hash', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_magic_link_tokens_token_hash'), 'magic_link_tokens', ['token_hash'], unique=True) + op.create_index(op.f('ix_magic_link_tokens_user_id'), 'magic_link_tokens', ['user_id'], unique=False) + op.create_table('user_sessions', + sa.Column('id', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_user_sessions_user_id'), 'user_sessions', ['user_id'], unique=False) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index(op.f('ix_user_sessions_user_id'), table_name='user_sessions') + op.drop_table('user_sessions') + op.drop_index(op.f('ix_magic_link_tokens_user_id'), table_name='magic_link_tokens') + op.drop_index(op.f('ix_magic_link_tokens_token_hash'), table_name='magic_link_tokens') + op.drop_table('magic_link_tokens') + op.drop_index(op.f('ix_access_requests_status'), table_name='access_requests') + op.drop_index(op.f('ix_access_requests_email'), table_name='access_requests') + op.drop_table('access_requests') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..fb901c2 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,228 @@ +"""Phase 1 tests: magic-link auth, access requests, RBAC, cookie flags.""" + +import logging +from datetime import timedelta +from uuid import uuid4 + +import pytest +from sqlmodel import select + +from app.auth import service +from app.auth.deps import SESSION_COOKIE, resolve_tenant +from app.models import MagicLinkToken, User +from app.settings import settings + +ADMIN = "admin@example.com" +MEMBER = "member@example.com" + + +@pytest.fixture(autouse=True) +def _no_smtp_many_admins(monkeypatch): + # Never touch the network: dev fallback logs instead of mailing. + # (The real repo .env configures SMTP; tests must not use it.) + monkeypatch.setattr(settings, "smtp_host", "") + monkeypatch.setattr(settings, "admin_emails", ADMIN) + + +def _get_or_make_user(session, email, role="member"): + user = service.get_user_by_email(session, email) + if user is None: + user = User(email=email, name="Test", role=role) + session.add(user) + session.commit() + session.refresh(user) + return user + + +async def _login(client, session, email, role="member"): + """Consume a fresh magic token; persist the session cookie manually. + + httpx honours the Secure cookie flag and would withhold it over the + http://test transport, so the session id is re-set as a plain cookie. + """ + user = _get_or_make_user(session, email, role) + token = service.issue_magic_token(session, user) + response = await client.post("/api/auth/magic/consume", json={"token": token}) + assert response.status_code == 200, response.text + sid = response.cookies[SESSION_COOKIE] + client.cookies.set(SESSION_COOKIE, sid) + return response.json() + + +async def test_request_approve_consume_me_logout(client, session): + assert (await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})).status_code == 201 + admin = await _login(client, session, ADMIN, role="admin") + assert admin["role"] == "admin" + + created = await client.post( + "/api/access/requests", + json={"name": "Maria", "email": MEMBER, "reason": "choir planning"}, + ) + assert created.status_code == 201, created.text + assert created.json()["status"] == "pending" + + queue = await client.get("/api/access/requests") + assert queue.status_code == 200 + assert any(r["email"] == MEMBER for r in queue.json()) + + approved = await client.post(f"/api/access/requests/{created.json()['id']}/approve") + assert approved.status_code == 200, approved.text + body = approved.json() + assert body["request"]["status"] == "approved" + assert body["email_sent"] is False # no SMTP in tests + + # New client (no admin cookie) logs in as the approved member. + from httpx import ASGITransport, AsyncClient + + from app.main import app + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as member: + user = service.get_user_by_email(session, MEMBER) + assert user is not None and user.role == "member" + token = service.issue_magic_token(session, user) + consumed = await member.post("/api/auth/magic/consume", json={"token": token}) + assert consumed.status_code == 200 + assert consumed.json()["email"] == MEMBER + member.cookies.set(SESSION_COOKIE, consumed.cookies[SESSION_COOKIE]) + + me = await member.get("/api/auth/me") + assert me.status_code == 200 + assert me.json()["tenant_id"] == "default" + + assert (await member.post("/api/auth/logout")).status_code == 200 + assert (await member.get("/api/auth/me")).status_code == 401 + + +async def test_consume_unknown_token_400(client): + response = await client.post("/api/auth/magic/consume", json={"token": "nope"}) + assert response.status_code == 400 + + +async def test_token_expiry_boundary(client, session): + user = _get_or_make_user(session, MEMBER) + token = service.issue_magic_token(session, user) + record = session.exec( + select(MagicLinkToken).where( + MagicLinkToken.token_hash == service.hash_token(token) + ) + ).one() + record.expires_at = service.utcnow() - timedelta(seconds=1) + session.add(record) + session.commit() + + response = await client.post("/api/auth/magic/consume", json={"token": token}) + assert response.status_code == 400 + assert "expired" in response.json()["detail"] + + +async def test_token_reuse_400(client, session): + user = _get_or_make_user(session, MEMBER) + token = service.issue_magic_token(session, user) + assert (await client.post("/api/auth/magic/consume", json={"token": token})).status_code == 200 + again = await client.post("/api/auth/magic/consume", json={"token": token}) + assert again.status_code == 400 + assert "used" in again.json()["detail"] + + +async def test_admin_only_endpoints(client, session): + await _login(client, session, MEMBER, role="member") + created = await client.post( + "/api/access/requests", + json={"name": "X", "email": "x@example.com", "reason": ""}, + ) + assert created.status_code == 201 + + assert (await client.get("/api/access/requests")).status_code == 403 + approve = await client.post(f"/api/access/requests/{created.json()['id']}/approve") + assert approve.status_code == 403 + + from httpx import ASGITransport, AsyncClient + + from app.main import app + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as anon: + assert (await anon.get("/api/access/requests")).status_code == 401 + assert ( + await anon.post( + f"/api/access/requests/{created.json()['id']}/approve" + ) + ).status_code == 401 + assert (await anon.get("/api/auth/me")).status_code == 401 + + +async def test_approve_unknown_and_double_decide(client, session): + await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN}) + await _login(client, session, ADMIN, role="admin") + + assert ( + await client.post(f"/api/access/requests/{uuid4()}/approve") + ).status_code == 404 + + created = await client.post( + "/api/access/requests", + json={"name": "Y", "email": "y@example.com", "reason": ""}, + ) + rid = created.json()["id"] + assert (await client.post(f"/api/access/requests/{rid}/reject")).status_code == 200 + again = await client.post(f"/api/access/requests/{rid}/approve") + assert again.status_code == 409 + + +async def test_bootstrap_once_and_forbidden(client): + assert ( + await client.post("/api/auth/admin-bootstrap", json={"email": "stranger@example.com"}) + ).status_code == 403 + assert ( + await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN}) + ).status_code == 201 + assert ( + await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN}) + ).status_code == 409 + + +async def test_session_cookie_flags(client, session): + user = _get_or_make_user(session, MEMBER) + token = service.issue_magic_token(session, user) + response = await client.post("/api/auth/magic/consume", json={"token": token}) + assert response.status_code == 200 + raw = " ".join(response.headers.get_list("set-cookie")).lower() + assert "httponly" in raw + assert "secure" in raw + assert "samesite=lax" in raw + assert "path=/" in raw + + +async def test_no_token_or_session_in_logs(client, session, caplog): + user = _get_or_make_user(session, MEMBER) + token = service.issue_magic_token(session, user) + with caplog.at_level(logging.INFO): + caplog.clear() + response = await client.post("/api/auth/magic/consume", json={"token": token}) + assert response.status_code == 200 + assert token not in caplog.text + assert response.cookies[SESSION_COOKIE] not in caplog.text + assert service.hash_token(token) not in caplog.text + + +async def test_access_request_rate_limit(client): + email = f"impatient-{uuid4()}@example.com" + for _ in range(service.REQUESTS_PER_EMAIL_PER_HOUR): + response = await client.post( + "/api/access/requests", + json={"name": "I", "email": email, "reason": ""}, + ) + assert response.status_code == 201, response.text + limited = await client.post( + "/api/access/requests", json={"name": "I", "email": email, "reason": ""} + ) + assert limited.status_code == 429 + + +def test_resolve_tenant(): + assert resolve_tenant(None, None) == "default" + assert resolve_tenant(None, "acme") == "acme" + user = User(email="a@b.c", tenant_id="parish-7") + assert resolve_tenant(user, None) == "parish-7" + assert resolve_tenant(user, "other") == "parish-7"