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) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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: ...
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user