Files
espresso_frame/server/app/auth.py
T
tfaour 8e10ca540e
Build and push server image / build-and-push (push) Successful in 40s
Add SMTP email: password reset + per-frame battery-threshold alerts
Admin-configured SMTP (server/port/username/password/from address/
STARTTLS, a singleton server_settings row set from /admin -- not env
vars, since it's operator infrastructure a household admin sets up
once through the UI) powers two features, both requiring the relevant
user to have an email set in their own Settings:

- "Forgot password?" on /login emails a one-hour single-use reset link
  (password_reset_tokens table). The endpoint always returns the same
  generic "check your email" response regardless of whether the address
  matched an account, so it can't be used to enumerate registered users.
- A frame's Configuration tab can set a battery-alert threshold
  (Frame.battery_alert_threshold_pct, -1 = disabled); POST /frame/battery
  emails the owner the first time a report drops to or below it, then
  stays quiet for the rest of that discharge cycle (battery_alert_sent,
  reset alongside battery_history whenever the existing recharge-jump
  detection fires) -- not once per wake.

New app/mail.py wraps stdlib smtplib (no new dependency); send_email()
never raises, so a broken mail server can't 500 a battery report or a
password-reset request. Schema migration v2 adds users.email and the
two frame columns via ALTER TABLE (safe against the live, already-
populated database) plus the two new tables via the existing
create_all-based migration runner.

Verified against a real (already-migrated, real user/frame data)
database: the v1->v2 migration, admin SMTP config + test-email button,
full forgot/reset-password roundtrip (including single-use token
invalidation and the no-enumeration response), and the battery alert
firing exactly once per crossing against a hand-rolled fake SMTP
server -- all via curl end-to-end, plus the standing legacy-device
curl suite to confirm the device protocol is untouched.
2026-07-22 00:51:54 -04:00

390 lines
15 KiB
Python

"""Authentication: password hashing, user sessions + CSRF, the legacy
shared-token gate, and device resolution.
Three independent credential classes:
- User sessions (cookie "session", server-side sessions table, per-
session CSRF token required on mutating requests) -- humans.
- The legacy shared MANAGEMENT_TOKEN (env-only). Still accepted on
browser routes so the deployed frame's on-panel manage QR (which
embeds ?token=) keeps working until Phase C replaces it with the
limited /m/ page; CSRF doesn't apply to it (it's explicit per-request
credential, not an ambient cookie a cross-site request could ride).
- Device credentials (?id= + ?token=, see require_device below).
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import time
from fastapi import Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from .db import get_db
from .migration import new_device_token, new_manage_token
from .models import Frame, PasswordResetToken, PendingClaim, ServerSettings, User, UserFrame, UserSession
logger = logging.getLogger(__name__)
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
SESSION_COOKIE = "session"
SESSION_LIFETIME_S = 30 * 86400
SESSION_REFRESH_BELOW_S = 15 * 86400 # rolling expiry: extend when under this much left
PASSWORD_RESET_TOKEN_LIFETIME_S = 3600
# stdlib scrypt instead of a passlib/argon2 dependency: zero new deps,
# and the parameters are baked into each stored hash so they can be
# raised later without invalidating existing ones.
_SCRYPT_N = 16384
_SCRYPT_R = 8
_SCRYPT_P = 1
def hash_password(password: str) -> str:
salt = os.urandom(16)
digest = hashlib.scrypt(
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P
)
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${digest.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
scheme, n, r, p, salt_hex, hash_hex = stored.split("$")
if scheme != "scrypt":
return False
digest = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt_hex), n=int(n), r=int(r), p=int(p)
)
return hmac.compare_digest(digest.hex(), hash_hex)
except (ValueError, AttributeError):
return False
def _hash_session_token(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
def create_session(db: Session, user: User) -> tuple[str, UserSession]:
"""Returns (cookie_value, session row). Only the sha256 of the cookie
value is stored, so a leaked database doesn't yield usable cookies."""
cookie_value = secrets.token_urlsafe(32)
now = time.time()
session = UserSession(
token_hash=_hash_session_token(cookie_value),
user_id=user.id,
csrf_token=secrets.token_urlsafe(32),
created_at=now,
expires_at=now + SESSION_LIFETIME_S,
)
db.add(session)
# Opportunistic prune -- no background scheduler in this project.
for stale in db.scalars(select(UserSession).where(UserSession.expires_at < now)):
db.delete(stale)
db.commit()
return cookie_value, session
def destroy_session(db: Session, request: Request) -> None:
cookie_value = request.cookies.get(SESSION_COOKIE)
if not cookie_value:
return
session = db.scalars(
select(UserSession).where(UserSession.token_hash == _hash_session_token(cookie_value))
).first()
if session is not None:
db.delete(session)
db.commit()
def current_session(request: Request, db: Session) -> UserSession | None:
cookie_value = request.cookies.get(SESSION_COOKIE)
if not cookie_value:
return None
session = db.scalars(
select(UserSession).where(UserSession.token_hash == _hash_session_token(cookie_value))
).first()
now = time.time()
if session is None or session.expires_at < now:
return None
if session.expires_at - now < SESSION_REFRESH_BELOW_S:
session.expires_at = now + SESSION_LIFETIME_S
db.commit()
return session
def current_user(request: Request, db: Session) -> User | None:
session = current_session(request, db)
if session is None:
return None
return db.get(User, session.user_id)
def users_exist(db: Session) -> bool:
return db.scalars(select(User).limit(1)).first() is not None
def get_server_settings(db: Session) -> ServerSettings:
"""The SMTP config singleton -- migration.py guarantees row id=1
exists (created at startup if missing), so this is never None."""
settings = db.get(ServerSettings, 1)
assert settings is not None
return settings
def create_password_reset_token(db: Session, user: User) -> str:
token = secrets.token_urlsafe(32)
now = time.time()
# Opportunistic prune, same pattern as sessions/pending claims.
for stale in db.scalars(select(PasswordResetToken).where(PasswordResetToken.expires_at < now)):
db.delete(stale)
db.add(PasswordResetToken(
token=token, user_id=user.id, created_at=now,
expires_at=now + PASSWORD_RESET_TOKEN_LIFETIME_S,
))
db.commit()
return token
def consume_password_reset_token(db: Session, token: str) -> User | None:
"""Looks up the token and, if valid, deletes it (single-use) and
returns the user it was issued for. None for an unknown/expired
token -- callers show a generic error either way."""
row = db.get(PasswordResetToken, token)
if row is None or row.expires_at < time.time():
return None
user = db.get(User, row.user_id)
db.delete(row)
db.commit()
return user
def _csrf_ok(request: Request, session: UserSession) -> bool:
supplied = request.headers.get("X-CSRF-Token") or ""
return hmac.compare_digest(supplied, session.csrf_token)
def require_user_api(request: Request, db: Session = Depends(get_db)) -> User:
"""JSON-API dependency: a logged-in user, with CSRF enforced on
mutating methods (the session rides an ambient cookie; the CSRF
header is what proves the request came from our own JS, not a
cross-site form)."""
session = current_session(request, db)
if session is None:
raise HTTPException(401, "Not logged in")
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
raise HTTPException(403, "Missing or invalid CSRF token")
user = db.get(User, session.user_id)
if user is None:
raise HTTPException(401, "Not logged in")
return user
def require_admin_api(request: Request, db: Session = Depends(get_db)) -> User:
user = require_user_api(request, db)
if not user.is_admin:
raise HTTPException(403, "Admin only")
return user
def user_frames(db: Session, user: User) -> list[Frame]:
"""The frames this user sees in their sidebar: linked ones, or all of
them for an admin (admins are the household operators -- they see
unclaimed/new frames too, that's how those get adopted)."""
if user.is_admin:
return list(db.scalars(select(Frame).order_by(Frame.id)))
return list(
db.scalars(
select(Frame)
.join(UserFrame, UserFrame.frame_id == Frame.id)
.where(UserFrame.user_id == user.id)
.order_by(Frame.id)
)
)
def can_view_frame(db: Session, user: User, frame: Frame) -> bool:
return user.is_admin or db.get(UserFrame, (user.id, frame.id)) is not None
def require_frame_view(
frame_id: int, request: Request, db: Session = Depends(get_db)
) -> Frame:
"""JSON-API dependency: a logged-in user who is linked to this frame
(or an admin). 404 -- not 403 -- for frames outside the user's view,
so the API doesn't confirm which frame ids exist."""
user = require_user_api(request, db)
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
return frame
def require_frame_control(
frame_id: int, request: Request, db: Session = Depends(get_db)
) -> Frame:
"""View access plus the soft control lock: only the user currently
holding control may mutate settings/queue. The 409 payload names the
holder so the UI can offer "take control" instead of a dead end.
Physical device buttons don't go through this -- device actions are
device actions."""
user = require_user_api(request, db)
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
if frame.controlled_by_user_id != user.id:
holder = frame.controlled_by
raise HTTPException(
409,
{
"error": "not_controller",
"holder": (holder.display_name or holder.username) if holder else None,
},
)
return frame
def management_token() -> str:
"""The legacy shared secret. Env-only, never stored -- same as the old
server, where the env var overrode anything on disk on every load."""
return os.environ.get("MANAGEMENT_TOKEN", "")
def browser_token_valid(request: Request) -> bool:
"""The legacy shared-token check. No MANAGEMENT_TOKEN configured means
token-holders don't exist -- but unlike Phase A this no longer means
"open": once users exist, sessions are the primary gate and this is
only the compatibility path for the deployed frame's manage QR
(?token=) until Phase C. Empty token => not valid (sessions rule)."""
token = management_token()
if not token:
return False
supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE)
return supplied is not None and supplied == token
def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None:
"""Dependency for the web UI's /api/* routes: a real user session
(CSRF-checked on mutations, returns the User), or the legacy shared
token (returns None -- token bearers act as an anonymous operator,
exactly the pre-user model). While NO users exist yet (fresh install
or freshly migrated, before /setup has been run) the API stays open
if no MANAGEMENT_TOKEN is set -- the Phase A/legacy behavior --
since there's nobody to log in as yet."""
session = current_session(request, db)
if session is not None:
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
raise HTTPException(403, "Missing or invalid CSRF token")
user = db.get(User, session.user_id)
if user is not None:
return user
if browser_token_valid(request):
return None
if not users_exist(db) and not management_token():
return None
raise HTTPException(401, "Not logged in")
def _register_frame(db: Session, device_id: str) -> Frame:
"""A device id we've never seen: self-register it as an unclaimed
frame (this fires from ANY /frame/* route -- the wake cycle hits
/frame/image before /frame/config). If a user already submitted a
claim for this id (they beat the device to the server after
provisioning), attach it now."""
frame = Frame(
name=f"Frame {device_id[-6:]}",
device_id=device_id,
device_token=new_device_token(),
manage_token=new_manage_token(),
created_at=time.time(),
)
db.add(frame)
db.flush()
now = time.time()
# Opportunistically prune expired claims while we're here.
for stale in db.scalars(select(PendingClaim).where(PendingClaim.expires_at < now)):
db.delete(stale)
pending = db.get(PendingClaim, device_id)
if pending is not None and pending.expires_at >= now:
frame.owner_user_id = pending.user_id
frame.claimed_at = now
db.add(UserFrame(user_id=pending.user_id, frame_id=frame.id))
db.delete(pending)
logger.info("Frame %s self-registered and attached pending claim by user %d",
device_id, pending.user_id)
else:
logger.info("Frame %s self-registered (unclaimed)", device_id)
return frame
def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
"""Resolves and authenticates the frame behind a /frame/* request.
New firmware sends ?id=<12-hex-mac>&token=<per-frame device token>.
Deployed legacy firmware sends only ?token=<shared MANAGEMENT_TOKEN>
(or nothing, on an open server) -- those requests resolve to the
unique legacy_token_enabled frame for as long as that migration
window stays open. The first id-bearing request arriving with legacy
credentials while the legacy frame has no device_id yet BINDS that id
to it -- that's the moment the deployed frame comes back up on new
firmware after its OTA, and it must not register as a second frame.
"""
device_id = request.query_params.get("id", "").strip().lower()
token = request.query_params.get("token", "")
legacy = management_token()
legacy_ok = not legacy or token == legacy
if device_id:
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is None:
legacy_frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if legacy_frame is not None and legacy_frame.device_id is None and legacy_ok:
legacy_frame.device_id = device_id
frame = legacy_frame
logger.info("Bound device id %s to legacy frame #%d", device_id, frame.id)
else:
frame = _register_frame(db, device_id)
else:
token_ok = bool(token) and token == frame.device_token
if token_ok and not frame.device_token_ack:
frame.device_token_ack = True
logger.info("Frame #%d acknowledged its device token", frame.id)
if not token_ok:
if frame.legacy_token_enabled and legacy_ok:
pass
elif not frame.device_token_ack:
# Handshake window: the device registered but hasn't
# received its token yet (the wake cycle fetches the
# image BEFORE polling /frame/config, where the token
# is delivered) -- the id stays the credential, same
# trust level as the open registration that created
# the row. Closes permanently on the first
# authenticated request.
pass
else:
raise HTTPException(401, "Missing or invalid access token")
else:
if not legacy_ok:
raise HTTPException(401, "Missing or invalid access token")
frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if frame is None:
# Nothing to resolve a no-id request to. migration.py always
# creates frame #1 at startup, so this only happens if it was
# deleted -- treat like an unknown device.
raise HTTPException(401, "No frame accepts legacy credentials")
frame.last_seen = time.time()
db.commit()
return frame