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
261 lines
8.8 KiB
Python
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})
|