00bfe8bfca
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>
202 lines
6.4 KiB
Python
202 lines
6.4 KiB
Python
"""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()
|