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 ` 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/") @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})