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,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()
|
||||
Reference in New Issue
Block a user