Files
inkwell/src/thoughtsync/auth.py
T
bvandeusenandClaude Opus 4.8 bdc5504419
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 34s
M9 S5 (backend): _serialize_device adopts common.iso()
The two `x.isoformat() if x else None` copies in auth's device serializer
now use the shared iso() helper — completes the isoformat-idiom sweep
outside notes.py (auth + sync done; notes.py's remain, tied to its split).
_serialize_user is unchanged (no datetime, no cross-module duplicate) and
stays in auth.py rather than relocating for no DRY gain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 21:51:27 -04:00

261 lines
8.8 KiB
Python

from __future__ import annotations
import functools
import uuid
from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request, session
from sqlalchemy import func, select
from .common import iso
from .db import session_scope
from .models.device_token import DeviceToken
from .models.user import User
from .security import generate_token, hash_password, hash_token, verify_password
from .settings import get_setting
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
SESSION_KEY = "user_id"
MIN_PASSWORD_LEN = 8
DEVICE_NAME_CAP = 100
def _serialize_user(user: User) -> dict:
return {
"id": str(user.id),
"email": user.email,
"display_name": user.display_name,
"email_verified": user.email_verified,
"is_admin": user.is_admin,
}
def _session_user_id() -> uuid.UUID | None:
raw = session.get(SESSION_KEY)
if not raw:
return None
try:
return uuid.UUID(raw)
except (ValueError, TypeError):
session.pop(SESSION_KEY, None)
return None
def _bearer_token() -> str | None:
"""Extract a `Authorization: Bearer <token>` device token, if present."""
header = request.headers.get("Authorization", "")
if header.startswith("Bearer "):
return header[7:].strip() or None
return None
async def _user_id_from_bearer() -> uuid.UUID | None:
"""Resolve a device bearer token to its owner, refreshing last_used_at. Native
clients (Tauri/Android) authenticate sync this way instead of a session cookie."""
token = _bearer_token()
if not token:
return None
async with session_scope() as db:
row = await db.scalar(select(DeviceToken).where(DeviceToken.token_hash == hash_token(token)))
if row is None:
return None
# Cheap liveness stamp; sync calls are user-initiated/periodic, not per-keystroke.
row.last_used_at = datetime.now(timezone.utc)
await db.commit()
return row.user_id
def login_required(fn):
"""Guard: 401 unless authenticated. Accepts a web session cookie OR a device
bearer token (native clients). Sets g.user_id for the view. The session path
stays DB-free (fast); only bearer auth does a token lookup."""
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
uid = _session_user_id()
if uid is None:
uid = await _user_id_from_bearer()
if uid is None:
return jsonify({"error": "authentication required"}), 401
g.user_id = uid
return await fn(*args, **kwargs)
return wrapper
def require_admin(fn):
"""Guard: 401 unauthenticated, 403 non-admin. Checks is_admin live from the DB
so a demoted admin loses access immediately."""
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
uid = _session_user_id()
if uid is None:
return jsonify({"error": "authentication required"}), 401
async with session_scope() as db:
user = await db.get(User, uid)
if user is None:
session.pop(SESSION_KEY, None)
return jsonify({"error": "authentication required"}), 401
if not user.is_admin:
return jsonify({"error": "admin access required"}), 403
g.user_id = uid
return await fn(*args, **kwargs)
return wrapper
@bp.post("/register")
async def register():
data = await request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
display_name = (data.get("display_name") or "").strip()
if not email or "@" not in email:
return jsonify({"error": "a valid email is required"}), 400
if len(password) < MIN_PASSWORD_LEN:
return jsonify({"error": f"password must be at least {MIN_PASSWORD_LEN} characters"}), 400
if not display_name:
display_name = email.split("@", 1)[0]
async with session_scope() as db:
user_count = await db.scalar(select(func.count()).select_from(User)) or 0
is_first = user_count == 0
# The first account bootstraps the admin and is always allowed, even when
# registration is otherwise closed.
if not is_first and not await get_setting(db, "allow_registration"):
return jsonify({"error": "registration is closed"}), 403
existing = await db.scalar(select(User).where(User.email == email))
if existing is not None:
return jsonify({"error": "an account with that email already exists"}), 409
user = User(
email=email,
password_hash=hash_password(password),
display_name=display_name,
is_admin=is_first,
)
db.add(user)
await db.commit()
await db.refresh(user)
session[SESSION_KEY] = str(user.id)
session.permanent = True
return jsonify(_serialize_user(user)), 201
@bp.post("/login")
async def login():
data = await request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
async with session_scope() as db:
user = await db.scalar(select(User).where(User.email == email))
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
return jsonify({"error": "invalid email or password"}), 401
session[SESSION_KEY] = str(user.id)
session.permanent = True
return jsonify(_serialize_user(user))
@bp.post("/logout")
async def logout():
session.pop(SESSION_KEY, None)
return jsonify({"ok": True})
@bp.get("/me")
@login_required
async def me():
async with session_scope() as db:
user = await db.get(User, g.user_id)
if user is None:
session.pop(SESSION_KEY, None)
return jsonify({"error": "authentication required"}), 401
return jsonify(_serialize_user(user))
# --- Device (bearer) tokens for native clients — M8 sync hub ---
def _serialize_device(d: DeviceToken) -> dict:
return {
"id": str(d.id),
"name": d.name,
"created_at": iso(d.created_at),
"last_used_at": iso(d.last_used_at),
}
async def _issue_device_token(db, user_id: uuid.UUID, name: str) -> tuple[DeviceToken, str]:
"""Create a device token; return the row plus the ONE-TIME plaintext token."""
token = generate_token()
row = DeviceToken(
user_id=user_id,
token_hash=hash_token(token),
name=(name or "").strip()[:DEVICE_NAME_CAP] or "Device",
)
db.add(row)
await db.flush()
return row, token
@bp.post("/device-login")
async def device_login():
"""Native first-link: exchange email+password for a device bearer token. Public
(no existing session) — this is how a fresh native install authenticates."""
data = await request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
if not email or not password:
return jsonify({"error": "email and password are required"}), 400
async with session_scope() as db:
user = await db.scalar(select(User).where(User.email == email))
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
return jsonify({"error": "invalid email or password"}), 401
row, token = await _issue_device_token(db, user.id, data.get("name") or "")
await db.commit()
return jsonify({"token": token, "device": _serialize_device(row), "user": _serialize_user(user)}), 201
@bp.post("/devices")
@login_required
async def create_device():
"""Issue a device token for the already-authenticated user (web 'Link a device')."""
data = await request.get_json(silent=True) or {}
async with session_scope() as db:
row, token = await _issue_device_token(db, g.user_id, data.get("name") or "")
await db.commit()
return jsonify({"token": token, "device": _serialize_device(row)}), 201
@bp.get("/devices")
@login_required
async def list_devices():
async with session_scope() as db:
rows = (
await db.scalars(
select(DeviceToken).where(DeviceToken.user_id == g.user_id).order_by(DeviceToken.created_at.desc())
)
).all()
return jsonify({"devices": [_serialize_device(d) for d in rows]})
@bp.delete("/devices/<device_id>")
@login_required
async def revoke_device(device_id: str):
try:
did = uuid.UUID(device_id)
except (ValueError, TypeError):
return jsonify({"error": "not found"}), 404
async with session_scope() as db:
row = await db.scalar(
select(DeviceToken).where(DeviceToken.id == did, DeviceToken.user_id == g.user_id)
)
if row is None:
return jsonify({"error": "not found"}), 404
await db.delete(row)
await db.commit()
return jsonify({"ok": True})