S.H.O.N.A.R._Desktop_Companion/backend/shonar/services/auth.py
avi 76c867fca4 Standalone Shonar Desktop: vendor portable sources + local engine; decouple from ~/Projects/Shonar
- shared/ = portable Android-origin sources vendored from deferred/desktop-server
  (app/build.gradle.kts srcDir repointed; PlaybackController.kt excluded as Android-only)
- backend/ = bundled-lite engine (SQLite + inline queue); .venv symlinked from the
  old checkout, PYTHONPATH pins THIS backend's code over any editable install
- repoRoot() resolves this project dir (env SHONAR_REPO still wins); desktop-dev.sh
  watches shared/ + backend/
- Verified: :app:compileKotlin + :app:test green (23 tests); engine boots on :8010,
  self-migrates, /healthz ok
2026-09-14 17:14:54 -05:00

167 lines
5.8 KiB
Python

"""Authentication service: registration, login, rotating refresh tokens with
reuse detection, logout, account deletion."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from shonar.core.security import (
generate_refresh_token,
hash_password,
hash_refresh_token,
refresh_token_ttl,
verify_password,
)
from shonar.db.models import Device, RefreshToken, User, utcnow
class AuthError(Exception):
"""Safe, client-displayable auth failure (never leaks which half failed
beyond what the flow requires)."""
def __init__(self, message: str, status_code: int = 401):
super().__init__(message)
self.message = message
self.status_code = status_code
async def register_user(
session: AsyncSession, email: str, password: str, display_name: str | None
) -> User:
email = email.strip().lower()
existing = await session.scalar(select(User).where(User.email == email))
if existing is not None:
# Use a generic message; do not reveal whether the account exists in
# flows where that matters. For self-hosted registration the UX cost
# of "email already registered" is acceptable and helpful.
raise AuthError("An account with this email already exists.", 409)
user = User(
email=email,
password_hash=hash_password(password),
display_name=display_name,
)
session.add(user)
await session.flush()
return user
async def issue_refresh_token(
session: AsyncSession,
user_id: uuid.UUID,
family: uuid.UUID | None,
device_id: uuid.UUID | None,
) -> tuple[str, RefreshToken]:
token = generate_refresh_token()
rt = RefreshToken(
user_id=user_id,
token_hash=hash_refresh_token(token),
family=family or uuid.uuid4(),
device_id=device_id,
expires_at=datetime.now(UTC) + refresh_token_ttl(),
)
session.add(rt)
await session.flush()
return token, rt
async def login(
session: AsyncSession,
email: str,
password: str,
device_name: str | None,
platform: str,
) -> tuple[User, str, Device]:
"""Returns (user, refresh_token, device). Raises AuthError safely."""
email = email.strip().lower()
user = await session.scalar(select(User).where(User.email == email))
if user is None or user.deleted_at is not None or not user.is_active:
raise AuthError("Invalid email or password.")
if not verify_password(user.password_hash, password):
raise AuthError("Invalid email or password.")
device = Device(user_id=user.id, name=device_name or "Android device", platform=platform)
session.add(device)
await session.flush()
refresh_token, _ = await issue_refresh_token(session, user.id, None, device.id)
return user, refresh_token, device
async def rotate_refresh_token(
session: AsyncSession, presented_token: str
) -> tuple[User, str, uuid.UUID | None]:
"""Consume a refresh token and issue a replacement in the same family.
Reuse detection: presenting an already-consumed/revoked token revokes the
entire family (an attacker's stolen token dies along with the real one).
"""
token_hash = hash_refresh_token(presented_token)
rt = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
now = utcnow()
if rt is None:
raise AuthError("Invalid refresh token.")
if rt.revoked_at is not None or rt.replaced_by is not None:
# REUSE DETECTED — revoke the whole family. Commit BEFORE raising:
# the request's transaction would otherwise roll back on the 401 and
# silently undo the security-revocation.
await session.execute(
update(RefreshToken)
.where(RefreshToken.family == rt.family, RefreshToken.revoked_at.is_(None))
.values(revoked_at=now)
)
await session.commit()
raise AuthError("Refresh token reuse detected. Please log in again.", 401)
if rt.expires_at < now:
raise AuthError("Refresh token expired.", 401)
user = await session.get(User, rt.user_id)
if user is None or user.deleted_at is not None or not user.is_active:
raise AuthError("Account unavailable.", 401)
new_token, new_rt = await issue_refresh_token(session, user.id, rt.family, rt.device_id)
rt.revoked_at = now
rt.replaced_by = new_rt.id
if rt.device_id is not None:
device = await session.get(Device, rt.device_id)
if device is not None:
device.last_seen_at = now
await session.flush()
return user, new_token, rt.device_id
async def logout(session: AsyncSession, presented_token: str) -> None:
"""Revoke the presented token's whole family (logs the device out)."""
token_hash = hash_refresh_token(presented_token)
rt = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
if rt is None:
return # idempotent
await session.execute(
update(RefreshToken)
.where(RefreshToken.family == rt.family, RefreshToken.revoked_at.is_(None))
.values(revoked_at=utcnow())
)
async def delete_account(session: AsyncSession, user: User, password: str) -> None:
if not verify_password(user.password_hash, password):
raise AuthError("Invalid password.", 403)
now = utcnow()
user.deleted_at = now
user.is_active = False
# Revoke every refresh token for the user.
await session.execute(
update(RefreshToken)
.where(RefreshToken.user_id == user.id, RefreshToken.revoked_at.is_(None))
.values(revoked_at=now)
)
# NOTE: hard deletion of rows/files is performed by the retention sweep
# (services/retention.py) so an accidental deletion can be cancelled
# within the grace window (see docs/security.md).