From 00bfe8bfcaa23464bf5b681bbf9571a66796aecc Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 10:51:51 -0400 Subject: [PATCH] Add local auth: AuthProvider, mailer, sessions, /api/v1/auth Pluggable AuthProvider interface with a local (email+password) implementation, and a Mailer interface (ConsoleMailer for dev, SMTPMailer for operators). The auth service owns registration, login, opaque session issuance, email verification, and password reset (which revokes prior sessions). Endpoints under /api/v1/auth; sessions are returned as a Bearer token and set as an HttpOnly cookie. Replaces the temporary X-User-Id shim: get_current_user now resolves a real session (Bearer or cookie). The open user-bootstrap endpoint is gone (registration replaces it). App logging is configured so the ConsoleMailer's verification/reset links are visible to self-hosters. Verified end-to-end on the deploy target, including the email-verification flow. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/app/api/deps.py | 49 +++-- backend/app/api/v1/__init__.py | 3 +- backend/app/api/v1/auth.py | 81 ++++++++ backend/app/api/v1/users.py | 16 +- backend/app/integrations/__init__.py | 0 backend/app/integrations/auth/__init__.py | 0 backend/app/integrations/auth/base.py | 24 +++ backend/app/integrations/auth/local.py | 27 +++ backend/app/integrations/mailer/__init__.py | 0 backend/app/integrations/mailer/base.py | 16 ++ backend/app/integrations/mailer/console.py | 16 ++ backend/app/integrations/mailer/smtp.py | 43 +++++ backend/app/main.py | 17 ++ backend/app/schemas/auth.py | 35 ++++ backend/app/services/auth_service.py | 201 ++++++++++++++++++++ 15 files changed, 497 insertions(+), 31 deletions(-) create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/integrations/__init__.py create mode 100644 backend/app/integrations/auth/__init__.py create mode 100644 backend/app/integrations/auth/base.py create mode 100644 backend/app/integrations/auth/local.py create mode 100644 backend/app/integrations/mailer/__init__.py create mode 100644 backend/app/integrations/mailer/base.py create mode 100644 backend/app/integrations/mailer/console.py create mode 100644 backend/app/integrations/mailer/smtp.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/services/auth_service.py diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c9efb4f..637809a 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,33 +1,48 @@ -"""Shared API dependencies.""" +"""Shared API dependencies: DB session, the authenticated user, and the mailer.""" -import uuid from typing import Annotated -from fastapi import Depends, Header, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession +from app.core.config import get_settings from app.core.db import get_session +from app.integrations.mailer.base import Mailer +from app.integrations.mailer.console import ConsoleMailer +from app.integrations.mailer.smtp import SMTPMailer from app.models.user import User -from app.services.user_service import get_user +from app.services import auth_service SessionDep = Annotated[AsyncSession, Depends(get_session)] -async def get_current_user( - session: SessionDep, - x_user_id: Annotated[uuid.UUID | None, Header()] = None, -) -> User: - """TEMPORARY pre-auth shim: identifies the caller via the ``X-User-Id`` - header. Replaced by the AuthProvider (sessions/tokens) in the auth slice. - The assistant principal will also be minted here, scoped to its user.""" - if x_user_id is None: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, "X-User-Id header required (pre-auth)" - ) - user = await get_user(session, x_user_id) +def extract_session_token(request: Request) -> str | None: + """Bearer header (API clients) takes precedence over the session cookie + (browser).""" + authorization = request.headers.get("authorization") + if authorization and authorization.lower().startswith("bearer "): + return authorization[7:].strip() + return request.cookies.get(get_settings().cookie_name) + + +async def get_current_user(request: Request, session: SessionDep) -> User: + raw_token = extract_session_token(request) + if raw_token is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "authentication required") + user = await auth_service.resolve_session_user(session, raw_token=raw_token) if user is None: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "unknown user") + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid or expired session") return user CurrentUser = Annotated[User, Depends(get_current_user)] + + +def get_mailer() -> Mailer: + settings = get_settings() + if settings.mailer == "smtp" and settings.smtp_host: + return SMTPMailer(settings) + return ConsoleMailer() + + +MailerDep = Annotated[Mailer, Depends(get_mailer)] diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 7e4a596..ee7faa3 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -2,9 +2,10 @@ from fastapi import APIRouter -from app.api.v1 import persons, trees, users +from app.api.v1 import auth, persons, trees, users api_router = APIRouter(prefix="/api/v1") +api_router.include_router(auth.router) api_router.include_router(users.router) api_router.include_router(trees.router) api_router.include_router(persons.router) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..fc20c93 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,81 @@ +from fastapi import APIRouter, HTTPException, Request, Response, status + +from app.api.deps import MailerDep, SessionDep, extract_session_token +from app.core.config import get_settings +from app.schemas.auth import ( + LoginRequest, + PasswordResetConfirm, + PasswordResetRequest, + RegisterRequest, + SessionRead, + TokenRequest, +) +from app.schemas.user import UserRead +from app.services import auth_service + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def _set_session_cookie(response: Response, token: str) -> None: + settings = get_settings() + response.set_cookie( + settings.cookie_name, + token, + max_age=settings.session_ttl_days * 86400, + httponly=True, + secure=settings.cookie_secure, + samesite="lax", + ) + + +@router.post("/register", response_model=SessionRead, status_code=status.HTTP_201_CREATED) +async def register( + data: RegisterRequest, session: SessionDep, mailer: MailerDep, response: Response +) -> SessionRead: + user, token, expires_at = await auth_service.register( + session, + mailer, + email=data.email, + password=data.password, + display_name=data.display_name, + ) + _set_session_cookie(response, token) + return SessionRead(user=UserRead.model_validate(user), token=token, expires_at=expires_at) + + +@router.post("/login", response_model=SessionRead) +async def login(data: LoginRequest, session: SessionDep, response: Response) -> SessionRead: + result = await auth_service.login(session, email=data.email, password=data.password) + if result is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials") + user, token, expires_at = result + _set_session_cookie(response, token) + return SessionRead(user=UserRead.model_validate(user), token=token, expires_at=expires_at) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout(request: Request, session: SessionDep, response: Response) -> None: + raw_token = extract_session_token(request) + if raw_token: + await auth_service.logout(session, raw_token=raw_token) + response.delete_cookie(get_settings().cookie_name) + + +@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT) +async def verify_email(data: TokenRequest, session: SessionDep) -> None: + await auth_service.verify_email(session, raw_token=data.token) + + +@router.post("/request-password-reset", status_code=status.HTTP_202_ACCEPTED) +async def request_password_reset( + data: PasswordResetRequest, session: SessionDep, mailer: MailerDep +) -> dict: + await auth_service.request_password_reset(session, mailer, email=data.email) + return {"status": "accepted"} + + +@router.post("/reset-password", status_code=status.HTTP_204_NO_CONTENT) +async def reset_password(data: PasswordResetConfirm, session: SessionDep) -> None: + await auth_service.reset_password( + session, raw_token=data.token, new_password=data.new_password + ) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 6b12887..af0f451 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -1,21 +1,11 @@ -from fastapi import APIRouter, status +from fastapi import APIRouter -from app.api.deps import CurrentUser, SessionDep -from app.schemas.user import UserCreate, UserRead -from app.services import user_service +from app.api.deps import CurrentUser +from app.schemas.user import UserRead router = APIRouter(prefix="/users", tags=["users"]) -@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED) -async def create_user(data: UserCreate, session: SessionDep) -> UserRead: - # Open dev bootstrap until the auth slice; lets us create tree owners. - user = await user_service.create_user( - session, email=data.email, display_name=data.display_name - ) - return UserRead.model_validate(user) - - @router.get("/me", response_model=UserRead) async def read_me(current: CurrentUser) -> UserRead: return UserRead.model_validate(current) diff --git a/backend/app/integrations/__init__.py b/backend/app/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/integrations/auth/__init__.py b/backend/app/integrations/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/integrations/auth/base.py b/backend/app/integrations/auth/base.py new file mode 100644 index 0000000..0115bd2 --- /dev/null +++ b/backend/app/integrations/auth/base.py @@ -0,0 +1,24 @@ +"""AuthProvider interface. + +Operators enable any subset of providers (local, OIDC, social). A provider's +job is narrow: verify a credential and return the matching User (or None). +Session issuance, tokens, and registration live in the auth service and are +provider-agnostic, so adding OIDC/social later (Phase 5) is additive. +""" + +from abc import ABC, abstractmethod + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import User + + +class AuthProvider(ABC): + name: str + + @abstractmethod + async def authenticate( + self, session: AsyncSession, *, identifier: str, secret: str + ) -> User | None: + """Return the User if the credential is valid, else None.""" + raise NotImplementedError diff --git a/backend/app/integrations/auth/local.py b/backend/app/integrations/auth/local.py new file mode 100644 index 0000000..ad98acc --- /dev/null +++ b/backend/app/integrations/auth/local.py @@ -0,0 +1,27 @@ +"""Local (email + password) auth provider.""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import verify_password +from app.integrations.auth.base import AuthProvider +from app.models.user import User + + +class LocalAuthProvider(AuthProvider): + name = "local" + + async def authenticate( + self, session: AsyncSession, *, identifier: str, secret: str + ) -> User | None: + email = identifier.strip().lower() + user = ( + await session.execute( + select(User).where(User.email == email, User.deleted_at.is_(None)) + ) + ).scalar_one_or_none() + if user is None or user.hashed_password is None: + return None + if not verify_password(user.hashed_password, secret): + return None + return user diff --git a/backend/app/integrations/mailer/__init__.py b/backend/app/integrations/mailer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/integrations/mailer/base.py b/backend/app/integrations/mailer/base.py new file mode 100644 index 0000000..c6749b2 --- /dev/null +++ b/backend/app/integrations/mailer/base.py @@ -0,0 +1,16 @@ +"""Mailer interface for transactional email. + +Implementations: ConsoleMailer (dev default — logs the link) and SMTPMailer +(operator-configured). Selected by config; resolved via app.api.deps.get_mailer. +Real deployments will move sending to the worker; for now it is inline. +""" + +from abc import ABC, abstractmethod + + +class Mailer(ABC): + @abstractmethod + async def send_email_verification(self, *, to: str, link: str) -> None: ... + + @abstractmethod + async def send_password_reset(self, *, to: str, link: str) -> None: ... diff --git a/backend/app/integrations/mailer/console.py b/backend/app/integrations/mailer/console.py new file mode 100644 index 0000000..6ee543a --- /dev/null +++ b/backend/app/integrations/mailer/console.py @@ -0,0 +1,16 @@ +"""Development mailer: logs the would-be email (including the action link) to +stdout instead of sending. Never use in production.""" + +import logging + +from app.integrations.mailer.base import Mailer + +logger = logging.getLogger("provenance.mailer") + + +class ConsoleMailer(Mailer): + async def send_email_verification(self, *, to: str, link: str) -> None: + logger.info("[email] verify address for %s -> %s", to, link) + + async def send_password_reset(self, *, to: str, link: str) -> None: + logger.info("[email] password reset for %s -> %s", to, link) diff --git a/backend/app/integrations/mailer/smtp.py b/backend/app/integrations/mailer/smtp.py new file mode 100644 index 0000000..8b3cff8 --- /dev/null +++ b/backend/app/integrations/mailer/smtp.py @@ -0,0 +1,43 @@ +"""SMTP mailer using the standard library, run off the event loop. Configured +entirely from settings (host/port/credentials/from).""" + +import asyncio +import smtplib +from email.message import EmailMessage + +from app.core.config import Settings +from app.integrations.mailer.base import Mailer + + +class SMTPMailer(Mailer): + def __init__(self, settings: Settings) -> None: + self.settings = settings + + def _send(self, *, to: str, subject: str, body: str) -> None: + msg = EmailMessage() + msg["From"] = self.settings.smtp_from + msg["To"] = to + msg["Subject"] = subject + msg.set_content(body) + with smtplib.SMTP(self.settings.smtp_host, self.settings.smtp_port) as smtp: + smtp.starttls() + if self.settings.smtp_username and self.settings.smtp_password: + smtp.login(self.settings.smtp_username, self.settings.smtp_password) + smtp.send_message(msg) + + async def _send_async(self, *, to: str, subject: str, body: str) -> None: + await asyncio.to_thread(self._send, to=to, subject=subject, body=body) + + async def send_email_verification(self, *, to: str, link: str) -> None: + await self._send_async( + to=to, + subject="Verify your Provenance email", + body=f"Confirm your email address:\n\n{link}\n", + ) + + async def send_password_reset(self, *, to: str, link: str) -> None: + await self._send_async( + to=to, + subject="Reset your Provenance password", + body=f"Reset your password:\n\n{link}\n", + ) diff --git a/backend/app/main.py b/backend/app/main.py index 2f3dfd6..af34fbf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,6 +5,9 @@ OpenAPI contract. All domain logic lives in the service layer; the privacy engine is the single enforcement point for reads. """ +import logging +import sys + from fastapi import FastAPI, Request from fastapi.responses import JSONResponse @@ -14,6 +17,19 @@ from app.core.config import get_settings from app.services.exceptions import Conflict, Forbidden, NotFound +def _configure_logging() -> None: + """Emit the app's own ``provenance.*`` logs at INFO to stdout (uvicorn only + configures its own loggers). The ConsoleMailer relies on this so self-hosters + can read verification/reset links from the logs.""" + app_logger = logging.getLogger("provenance") + app_logger.setLevel(logging.INFO) + if not app_logger.handlers: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(logging.Formatter("%(levelname)s [%(name)s] %(message)s")) + app_logger.addHandler(handler) + app_logger.propagate = False + + def _register_error_handlers(app: FastAPI) -> None: @app.exception_handler(NotFound) async def _not_found(request: Request, exc: NotFound) -> JSONResponse: @@ -29,6 +45,7 @@ def _register_error_handlers(app: FastAPI) -> None: def create_app() -> FastAPI: + _configure_logging() settings = get_settings() app = FastAPI( title=settings.app_name, diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..f3cb7b0 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,35 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.schemas.user import UserRead + + +class RegisterRequest(BaseModel): + email: str + password: str = Field(min_length=8) + display_name: str | None = None + + +class LoginRequest(BaseModel): + email: str + password: str + + +class TokenRequest(BaseModel): + token: str + + +class PasswordResetRequest(BaseModel): + email: str + + +class PasswordResetConfirm(BaseModel): + token: str + new_password: str = Field(min_length=8) + + +class SessionRead(BaseModel): + user: UserRead + token: str + expires_at: datetime diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..57aeaa7 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,201 @@ +"""Authentication service: registration, login, sessions, email verification, +and password reset. Provider-agnostic — credential checking is delegated to an +AuthProvider; this module owns session/token issuance and the audit trail. +""" + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.security import generate_token, hash_password, hash_token +from app.integrations.auth.local import LocalAuthProvider +from app.integrations.mailer.base import Mailer +from app.models.auth import Session as SessionModel +from app.models.auth import UserToken +from app.models.enums import TokenPurpose +from app.models.user import User +from app.services.audit import record_audit +from app.services.exceptions import Conflict, NotFound + +_local_provider = LocalAuthProvider() + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _link(path: str, raw: str) -> str: + return f"{get_settings().app_base_url}{path}?token={raw}" + + +def _issue_session(session: AsyncSession, user: User) -> tuple[str, SessionModel]: + raw = generate_token() + record = SessionModel( + user_id=user.id, + token_hash=hash_token(raw), + expires_at=_now() + timedelta(days=get_settings().session_ttl_days), + ) + session.add(record) + return raw, record + + +def _create_email_token(session: AsyncSession, user: User, purpose: TokenPurpose) -> str: + raw = generate_token() + session.add( + UserToken( + user_id=user.id, + purpose=purpose, + token_hash=hash_token(raw), + expires_at=_now() + timedelta(hours=get_settings().token_ttl_hours), + ) + ) + return raw + + +async def _consume_token( + session: AsyncSession, raw_token: str, purpose: TokenPurpose +) -> UserToken: + token = ( + await session.execute( + select(UserToken).where( + UserToken.token_hash == hash_token(raw_token), + UserToken.purpose == purpose, + ) + ) + ).scalar_one_or_none() + if token is None or token.used_at is not None or token.expires_at <= _now(): + raise NotFound("invalid or expired token") + token.used_at = _now() + return token + + +async def register( + session: AsyncSession, + mailer: Mailer, + *, + email: str, + password: str, + display_name: str | None = None, +) -> tuple[User, str, datetime]: + email = email.strip().lower() + existing = ( + await session.execute(select(User).where(User.email == email)) + ).scalar_one_or_none() + if existing is not None: + raise Conflict("email already registered") + + user = User(email=email, display_name=display_name, hashed_password=hash_password(password)) + session.add(user) + await session.flush() + + verify_raw = _create_email_token(session, user, TokenPurpose.email_verify) + raw_token, record = _issue_session(session, user) + record_audit( + session, + action="register", + entity_type="User", + entity_id=user.id, + actor_user_id=user.id, + after={"email": email}, + ) + await session.commit() + await session.refresh(user) + + await mailer.send_email_verification(to=email, link=_link("/auth/verify-email", verify_raw)) + return user, raw_token, record.expires_at + + +async def login( + session: AsyncSession, *, email: str, password: str +) -> tuple[User, str, datetime] | None: + user = await _local_provider.authenticate(session, identifier=email, secret=password) + if user is None: + return None + raw_token, record = _issue_session(session, user) + record_audit( + session, action="login", entity_type="User", entity_id=user.id, actor_user_id=user.id + ) + await session.commit() + return user, raw_token, record.expires_at + + +async def logout(session: AsyncSession, *, raw_token: str) -> None: + await session.execute( + update(SessionModel) + .where( + SessionModel.token_hash == hash_token(raw_token), + SessionModel.revoked_at.is_(None), + ) + .values(revoked_at=_now()) + ) + await session.commit() + + +async def resolve_session_user(session: AsyncSession, *, raw_token: str) -> User | None: + record = ( + await session.execute( + select(SessionModel).where(SessionModel.token_hash == hash_token(raw_token)) + ) + ).scalar_one_or_none() + if record is None or record.revoked_at is not None or record.expires_at <= _now(): + return None + return ( + await session.execute( + select(User).where(User.id == record.user_id, User.deleted_at.is_(None)) + ) + ).scalar_one_or_none() + + +async def verify_email(session: AsyncSession, *, raw_token: str) -> None: + token = await _consume_token(session, raw_token, TokenPurpose.email_verify) + await session.execute( + update(User).where(User.id == token.user_id).values(email_verified_at=_now()) + ) + record_audit( + session, + action="verify_email", + entity_type="User", + entity_id=token.user_id, + actor_user_id=token.user_id, + ) + await session.commit() + + +async def request_password_reset(session: AsyncSession, mailer: Mailer, *, email: str) -> None: + email = email.strip().lower() + user = ( + await session.execute( + select(User).where(User.email == email, User.deleted_at.is_(None)) + ) + ).scalar_one_or_none() + # Always succeed to avoid leaking which emails are registered. + if user is None: + return + raw = _create_email_token(session, user, TokenPurpose.password_reset) + await session.commit() + await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw)) + + +async def reset_password(session: AsyncSession, *, raw_token: str, new_password: str) -> None: + token = await _consume_token(session, raw_token, TokenPurpose.password_reset) + await session.execute( + update(User) + .where(User.id == token.user_id) + .values(hashed_password=hash_password(new_password)) + ) + # Revoke all existing sessions — a reset invalidates prior logins. + await session.execute( + update(SessionModel) + .where(SessionModel.user_id == token.user_id, SessionModel.revoked_at.is_(None)) + .values(revoked_at=_now()) + ) + record_audit( + session, + action="reset_password", + entity_type="User", + entity_id=token.user_id, + actor_user_id=token.user_id, + ) + await session.commit()