Files
wgBill/backend/app/auth.py
T
dodox 1b38396a2c Per-user auth via Nextcloud Login Flow v2, Tailwind UI rewrite
- auth.py/auth_routes.py: Login Flow v2 (verified against real NC
  source/docs) - no OAuth2 client registration needed, backend polls
  server-side so no CORS issues. Sessions are hashed-token cookies;
  NC app passwords encrypted at rest (Fernet). Every /api/* route
  guarded by a blueprint-wide before_request, not per-route decorators,
  so future routes are protected by default.
- receipts/groups scoped per owner_nc_user_id; cross-user access 404s.
- nc_client/cospend_client take (username, app_password) per call
  instead of one shared global credential - each user's uploads/shares/
  bills now happen as themselves.
- Frontend: LoginGate component drives the login flow (open NC login
  in a new tab, poll our backend, done).
- Merged the old separate review step into the split screen, redesigned
  with Tailwind (was unstyled/broken), default-excluded-per-item
  splitting with one-click "include everyone" fixed, DD.MM.YYYY date
  field, receipt-icon branding.
- Dropped LLM bounding-box highlighting - unreliable on real receipts,
  plain photo upload instead.
- Only mention who a bill is split with in its title when there's more
  than one bill off the same receipt to disambiguate.
2026-08-30 16:02:29 +02:00

214 lines
7.6 KiB
Python

"""Login via Nextcloud's Login Flow v2 (the same mechanism NC's own desktop/
mobile clients use to obtain an app password without ever seeing a
third-party app's code touch the user's real password).
Flow, doc-verified against docs.nextcloud.com/server/stable/developer_manual
/client_apis/LoginFlow/index.html:
1. POST {NC_BASE_URL}/index.php/login/v2 (no auth) ->
{poll: {token, endpoint}, login: <url the user opens>}
2. User opens `login`, authenticates directly with Nextcloud, approves.
3. POST {poll.endpoint} with body token=<poll.token> -> 404 while pending,
200 once (single-use) with {server, loginName, appPassword} once
granted. Token valid 20 minutes.
Because our backend does the polling (not browser JS), none of the CORS
issues that rule out a pure-frontend NC login apply here - the browser only
ever does a full-page navigation to NC's own login page and back.
"""
from __future__ import annotations
import hashlib
import secrets
import time
from datetime import datetime, timedelta
from functools import wraps
import requests
from cryptography.fernet import Fernet, InvalidToken
from flask import g, jsonify, request
from .config import Config
from .db import get_conn
# In-memory store for login flows in progress: flow_id -> {poll_token,
# poll_endpoint, created_at}. Short-lived (20 min NC-side) and low-stakes if
# lost on a backend restart (the user just retries the login button) - not
# worth a DB table.
_pending_flows: dict[str, dict] = {}
_FLOW_TTL_SECONDS = 20 * 60
def _fernet() -> Fernet:
if not Config.TOKEN_ENCRYPTION_KEY:
raise RuntimeError(
"TOKEN_ENCRYPTION_KEY is not set (see .env.example) - refusing to "
"store or read an NC app password without it"
)
return Fernet(Config.TOKEN_ENCRYPTION_KEY.encode())
def _hash_session_id(session_id: str) -> str:
return hashlib.sha256(session_id.encode()).hexdigest()
# ------------------------------------------------------------ login flow
def start_login_flow() -> dict:
"""Kicks off a Login Flow v2 against NC. Returns {flow_id, login_url}."""
resp = requests.post(f"{Config.NC_BASE_URL}/index.php/login/v2", timeout=15)
resp.raise_for_status()
data = resp.json()
flow_id = secrets.token_urlsafe(24)
_pending_flows[flow_id] = {
"poll_token": data["poll"]["token"],
"poll_endpoint": data["poll"]["endpoint"],
"created_at": time.time(),
}
_gc_expired_flows()
return {"flow_id": flow_id, "login_url": data["login"]}
def poll_login_flow(flow_id: str) -> dict:
"""Returns {"status": "pending"} | {"status": "expired"} |
{"status": "done", "nc_user_id": str, "session_id": str}.
On success, provisions/updates the user row and creates a new session -
the caller still has to actually set the cookie on the response.
"""
flow = _pending_flows.get(flow_id)
if flow is None:
return {"status": "expired"}
if time.time() - flow["created_at"] > _FLOW_TTL_SECONDS:
del _pending_flows[flow_id]
return {"status": "expired"}
resp = requests.post(
flow["poll_endpoint"], data={"token": flow["poll_token"]}, timeout=15
)
if resp.status_code == 404:
return {"status": "pending"}
resp.raise_for_status()
# Single-use - NC only returns this once, so this flow is done either way.
del _pending_flows[flow_id]
granted = resp.json()
nc_user_id = granted["loginName"]
app_password = granted["appPassword"]
encrypted = _fernet().encrypt(app_password.encode())
with get_conn() as conn:
conn.execute(
"""INSERT INTO users (nc_user_id, nc_app_password_encrypted)
VALUES (?, ?)
ON CONFLICT(nc_user_id) DO UPDATE SET
nc_app_password_encrypted = excluded.nc_app_password_encrypted,
last_login_at = datetime('now')""",
(nc_user_id, encrypted),
)
session_id = create_session(nc_user_id)
return {"status": "done", "nc_user_id": nc_user_id, "session_id": session_id}
def _gc_expired_flows() -> None:
now = time.time()
expired = [fid for fid, f in _pending_flows.items() if now - f["created_at"] > _FLOW_TTL_SECONDS]
for fid in expired:
del _pending_flows[fid]
# --------------------------------------------------------------- sessions
def create_session(nc_user_id: str) -> str:
"""Returns the raw session id to set as the cookie value."""
session_id = secrets.token_urlsafe(32)
expires_at = (datetime.utcnow() + timedelta(days=Config.SESSION_TTL_DAYS)).isoformat()
with get_conn() as conn:
conn.execute(
"INSERT INTO sessions (session_id_hash, nc_user_id, expires_at) VALUES (?, ?, ?)",
(_hash_session_id(session_id), nc_user_id, expires_at),
)
return session_id
def resolve_session(session_id: str) -> str | None:
"""Returns the nc_user_id for a valid, non-expired session, refreshing
its expiry (sliding session), or None if invalid/expired."""
session_hash = _hash_session_id(session_id)
with get_conn() as conn:
row = conn.execute(
"SELECT nc_user_id, expires_at FROM sessions WHERE session_id_hash = ?",
(session_hash,),
).fetchone()
if row is None:
return None
if datetime.fromisoformat(row["expires_at"]) < datetime.utcnow():
conn.execute("DELETE FROM sessions WHERE session_id_hash = ?", (session_hash,))
return None
new_expiry = (datetime.utcnow() + timedelta(days=Config.SESSION_TTL_DAYS)).isoformat()
conn.execute(
"UPDATE sessions SET expires_at = ? WHERE session_id_hash = ?",
(new_expiry, session_hash),
)
return row["nc_user_id"]
def destroy_session(session_id: str) -> None:
with get_conn() as conn:
conn.execute(
"DELETE FROM sessions WHERE session_id_hash = ?", (_hash_session_id(session_id),)
)
def get_app_password(nc_user_id: str) -> str:
with get_conn() as conn:
row = conn.execute(
"SELECT nc_app_password_encrypted FROM users WHERE nc_user_id = ?", (nc_user_id,)
).fetchone()
if row is None:
raise RuntimeError(f"no stored credentials for {nc_user_id!r}")
try:
return _fernet().decrypt(bytes(row["nc_app_password_encrypted"])).decode()
except InvalidToken as exc:
raise RuntimeError(
"stored app password could not be decrypted - TOKEN_ENCRYPTION_KEY "
"changed or is wrong"
) from exc
# ------------------------------------------------------------- decorator
def enforce_login():
"""Populates g.nc_user_id/g.nc_app_password from the session cookie, or
returns a 401 response if there isn't a valid one. Used both as a
blueprint-wide `before_request` (so any route added to that blueprint is
protected by default, not only ones someone remembered to decorate) and
directly by the `login_required` decorator for routes registered
elsewhere.
"""
session_id = request.cookies.get(Config.SESSION_COOKIE_NAME)
nc_user_id = resolve_session(session_id) if session_id else None
if nc_user_id is None:
return jsonify(error="not logged in"), 401
g.nc_user_id = nc_user_id
g.nc_app_password = get_app_password(nc_user_id)
return None
def login_required(view):
"""Per-route variant of enforce_login(), for routes on a blueprint that
isn't (or shouldn't be) guarded wholesale."""
@wraps(view)
def wrapped(*args, **kwargs):
rejection = enforce_login()
if rejection is not None:
return rejection
return view(*args, **kwargs)
return wrapped