Redesign phase B: users, sessions, first-run setup, admin panel
Real identity on top of phase A's schema: scrypt-hashed passwords (stdlib, no new deps -- parameters baked into each stored hash), server-side sessions (sha256 of the cookie value stored, 30-day rolling expiry), and per-session CSRF tokens enforced on every mutating session-authed request -- via X-CSRF-Token for the JSON API (a fetch() wrapper in base.html injects it, so the existing page scripts didn't need touching) and a hidden form field for the HTML forms. /setup runs once while no users exist: creates admin #1, links every existing frame to them (owner + controller), and inherits the migrated Immich creds onto their account -- per-user creds are now the primary source, with env vars still winning as the operator fallback. /login, /logout, /settings (display name, Immich creds, password change), and /admin (enroll users, reset passwords, link users to frames, close a frame's legacy-token window, delete) round out the pages, all in the existing template/card style. The legacy shared token stays accepted on browser routes so the deployed frame's on-panel manage QR keeps working until phase C swaps it for the limited manage page; token access renders without nav or CSRF shim and is exempt from CSRF (explicit credential, not an ambient cookie). Device routes untouched -- the legacy curl suite passes verbatim. Identity is provider-pluggable (identity_provider/provider_subject already modeled) so OIDC can land later without schema surgery.
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
"""HTML page routes: first-run setup, login/logout, user settings, and
|
||||
the admin panel. The frame pages themselves stay in main.py (Phase A's
|
||||
single-frame index) until the Phase D restructure.
|
||||
|
||||
All POSTs here are plain HTML forms, so CSRF rides a hidden form field
|
||||
(checked explicitly) rather than the X-CSRF-Token header the JSON API
|
||||
uses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import (
|
||||
SESSION_COOKIE,
|
||||
SESSION_LIFETIME_S,
|
||||
create_session,
|
||||
current_session,
|
||||
current_user,
|
||||
destroy_session,
|
||||
hash_password,
|
||||
users_exist,
|
||||
verify_password,
|
||||
)
|
||||
from ..db import get_db
|
||||
from ..models import Frame, User, UserFrame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
USERNAME_MAX_LEN = 64
|
||||
PASSWORD_MIN_LEN = 8
|
||||
|
||||
|
||||
def _set_session_cookie(response, cookie_value: str) -> None:
|
||||
# No Secure flag: the server itself is plain HTTP by design (TLS is a
|
||||
# reverse proxy's job, see README) and a LAN deployment without HTTPS
|
||||
# must still be able to log in.
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
cookie_value,
|
||||
max_age=SESSION_LIFETIME_S,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def _check_form_csrf(request: Request, db: Session, csrf_token: str) -> None:
|
||||
session = current_session(request, db)
|
||||
if session is None or not hmac.compare_digest(csrf_token, session.csrf_token):
|
||||
raise HTTPException(403, "Missing or invalid CSRF token")
|
||||
|
||||
|
||||
def _normalize_username(username: str) -> str:
|
||||
return username.strip().lower()
|
||||
|
||||
|
||||
def _validate_credentials(username: str, password: str) -> str:
|
||||
username = _normalize_username(username)
|
||||
if not username or len(username) > USERNAME_MAX_LEN:
|
||||
raise HTTPException(400, "Invalid username")
|
||||
if len(password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LEN} characters")
|
||||
return username
|
||||
|
||||
|
||||
@router.get("/setup", response_class=HTMLResponse)
|
||||
def setup_page(request: Request, db: Session = Depends(get_db)):
|
||||
if users_exist(db):
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
return templates.TemplateResponse("setup.html", {"request": request, "error": None})
|
||||
|
||||
|
||||
@router.post("/setup")
|
||||
def setup_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
display_name: str = Form(""),
|
||||
password: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Creates admin #1 -- only ever available while no users exist, so it
|
||||
needs no CSRF/session (there is nothing to ride). Links every
|
||||
existing frame (i.e. the migrated frame #1) to the new admin, makes
|
||||
them its owner + controller, and inherits the migrated Immich creds
|
||||
onto their account (that's how env/config.json creds become per-user
|
||||
state)."""
|
||||
if users_exist(db):
|
||||
raise HTTPException(403, "Setup has already been completed")
|
||||
username = _validate_credentials(username, password)
|
||||
|
||||
admin = User(
|
||||
username=username,
|
||||
display_name=display_name.strip() or username,
|
||||
password_hash=hash_password(password),
|
||||
is_admin=True,
|
||||
created_at=time.time(),
|
||||
)
|
||||
db.add(admin)
|
||||
db.flush()
|
||||
|
||||
for frame in db.scalars(select(Frame)):
|
||||
db.add(UserFrame(user_id=admin.id, frame_id=frame.id))
|
||||
if frame.owner_user_id is None:
|
||||
frame.owner_user_id = admin.id
|
||||
frame.claimed_at = time.time()
|
||||
if frame.controlled_by_user_id is None:
|
||||
frame.controlled_by_user_id = admin.id
|
||||
if not admin.immich_url and frame.immich_url and frame.immich_api_key:
|
||||
admin.immich_url = frame.immich_url
|
||||
admin.immich_api_key = frame.immich_api_key
|
||||
|
||||
db.commit()
|
||||
logger.info("First-run setup: created admin '%s' and linked %s", username,
|
||||
", ".join(f"frame #{f.id}" for f in db.scalars(select(Frame))) or "no frames")
|
||||
|
||||
cookie_value, _ = create_session(db, admin)
|
||||
response = RedirectResponse("/", status_code=303)
|
||||
_set_session_cookie(response, cookie_value)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_page(request: Request, db: Session = Depends(get_db)):
|
||||
if not users_exist(db):
|
||||
return RedirectResponse("/setup", status_code=303)
|
||||
if current_user(request, db) is not None:
|
||||
return RedirectResponse("/", status_code=303)
|
||||
return templates.TemplateResponse("login.html", {"request": request, "error": None})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = db.scalars(
|
||||
select(User).where(User.username == _normalize_username(username))
|
||||
).first()
|
||||
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{"request": request, "error": "Wrong username or password."},
|
||||
status_code=401,
|
||||
)
|
||||
cookie_value, _ = create_session(db, user)
|
||||
response = RedirectResponse("/", status_code=303)
|
||||
_set_session_cookie(response, cookie_value)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request, csrf_token: str = Form(""), db: Session = Depends(get_db)):
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
destroy_session(db, request)
|
||||
response = RedirectResponse("/login", status_code=303)
|
||||
response.delete_cookie(SESSION_COOKIE)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
def settings_page(request: Request, db: Session = Depends(get_db)):
|
||||
user = current_user(request, db)
|
||||
if user is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
session = current_session(request, db)
|
||||
return templates.TemplateResponse(
|
||||
"settings.html",
|
||||
{"request": request, "user": user, "csrf_token": session.csrf_token, "saved": False, "error": None},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/settings", response_class=HTMLResponse)
|
||||
def settings_submit(
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
display_name: str = Form(""),
|
||||
immich_url: str = Form(""),
|
||||
immich_api_key: str = Form(""),
|
||||
current_password: str = Form(""),
|
||||
new_password: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = current_user(request, db)
|
||||
if user is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
session = current_session(request, db)
|
||||
|
||||
error = None
|
||||
user.display_name = display_name.strip() or user.username
|
||||
user.immich_url = immich_url.strip()
|
||||
# Blank API key field = keep the existing one (it's never echoed back
|
||||
# into the form -- a secret that round-trips through HTML is a secret
|
||||
# in every browser's autofill store).
|
||||
if immich_api_key.strip():
|
||||
user.immich_api_key = immich_api_key.strip()
|
||||
|
||||
if new_password:
|
||||
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
||||
error = "Current password is wrong -- password not changed."
|
||||
elif len(new_password) < PASSWORD_MIN_LEN:
|
||||
error = f"New password must be at least {PASSWORD_MIN_LEN} characters."
|
||||
else:
|
||||
user.password_hash = hash_password(new_password)
|
||||
|
||||
db.commit()
|
||||
return templates.TemplateResponse(
|
||||
"settings.html",
|
||||
{"request": request, "user": user, "csrf_token": session.csrf_token,
|
||||
"saved": error is None, "error": error},
|
||||
)
|
||||
|
||||
|
||||
def _require_admin_page(request: Request, db: Session) -> User:
|
||||
user = current_user(request, db)
|
||||
if user is None or not user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
return user
|
||||
|
||||
|
||||
def _render_admin(request: Request, db: Session, admin: User, notice: str | None = None,
|
||||
error: str | None = None) -> HTMLResponse:
|
||||
session = current_session(request, db)
|
||||
users = list(db.scalars(select(User).order_by(User.id)))
|
||||
frames = list(db.scalars(select(Frame).order_by(Frame.id)))
|
||||
links = list(db.scalars(select(UserFrame)))
|
||||
links_by_frame: dict[int, list[User]] = {}
|
||||
users_by_id = {u.id: u for u in users}
|
||||
for link in links:
|
||||
links_by_frame.setdefault(link.frame_id, []).append(users_by_id[link.user_id])
|
||||
return templates.TemplateResponse(
|
||||
"admin.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"csrf_token": session.csrf_token,
|
||||
"users": users,
|
||||
"frames": frames,
|
||||
"links_by_frame": links_by_frame,
|
||||
"notice": notice,
|
||||
"error": error,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
def admin_page(request: Request, db: Session = Depends(get_db)):
|
||||
user = current_user(request, db)
|
||||
if user is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
return _render_admin(request, db, user)
|
||||
|
||||
|
||||
@router.post("/admin/users", response_class=HTMLResponse)
|
||||
def admin_create_user(
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
is_admin: bool = Form(False),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
username = _validate_credentials(username, password)
|
||||
if db.scalars(select(User).where(User.username == username)).first() is not None:
|
||||
return _render_admin(request, db, admin, error=f"Username '{username}' already exists.")
|
||||
db.add(User(
|
||||
username=username,
|
||||
display_name=username,
|
||||
password_hash=hash_password(password),
|
||||
is_admin=is_admin,
|
||||
created_at=time.time(),
|
||||
))
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin, notice=f"User '{username}' created.")
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/reset-password", response_class=HTMLResponse)
|
||||
def admin_reset_password(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
password: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
target = db.get(User, user_id)
|
||||
if target is None:
|
||||
return _render_admin(request, db, admin, error="No such user.")
|
||||
if len(password) < PASSWORD_MIN_LEN:
|
||||
return _render_admin(request, db, admin,
|
||||
error=f"Password must be at least {PASSWORD_MIN_LEN} characters.")
|
||||
target.password_hash = hash_password(password)
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin, notice=f"Password reset for '{target.username}'.")
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/delete", response_class=HTMLResponse)
|
||||
def admin_delete_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
if user_id == admin.id:
|
||||
return _render_admin(request, db, admin, error="You can't delete your own account.")
|
||||
target = db.get(User, user_id)
|
||||
if target is None:
|
||||
return _render_admin(request, db, admin, error="No such user.")
|
||||
name = target.username
|
||||
db.delete(target) # sessions/links cascade; frames.owner goes NULL
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin, notice=f"User '{name}' deleted.")
|
||||
|
||||
|
||||
@router.post("/admin/frames/{frame_id}/link-user", response_class=HTMLResponse)
|
||||
def admin_link_user(
|
||||
frame_id: int,
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
username: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
frame = db.get(Frame, frame_id)
|
||||
target = db.scalars(select(User).where(User.username == _normalize_username(username))).first()
|
||||
if frame is None or target is None:
|
||||
return _render_admin(request, db, admin, error="No such frame or user.")
|
||||
if db.get(UserFrame, (target.id, frame_id)) is not None:
|
||||
return _render_admin(request, db, admin, error=f"'{target.username}' is already linked.")
|
||||
db.add(UserFrame(user_id=target.id, frame_id=frame_id))
|
||||
if frame.owner_user_id is None:
|
||||
# Linking to an unclaimed frame claims it -- the admin flow for
|
||||
# adopting a frame that self-registered without a pending claim.
|
||||
frame.owner_user_id = target.id
|
||||
frame.claimed_at = time.time()
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin,
|
||||
notice=f"Linked '{target.username}' to frame #{frame_id}.")
|
||||
|
||||
|
||||
@router.post("/admin/frames/{frame_id}/end-legacy", response_class=HTMLResponse)
|
||||
def admin_end_legacy(
|
||||
frame_id: int,
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Closes the legacy-token migration window once the device is
|
||||
confirmed on per-frame auth (device_token_ack + recent last_seen in
|
||||
the frames table below)."""
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
frame = db.get(Frame, frame_id)
|
||||
if frame is None:
|
||||
return _render_admin(request, db, admin, error="No such frame.")
|
||||
frame.legacy_token_enabled = False
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin, notice=f"Legacy token disabled for frame #{frame_id}.")
|
||||
|
||||
|
||||
@router.post("/admin/frames/{frame_id}/delete", response_class=HTMLResponse)
|
||||
def admin_delete_frame(
|
||||
frame_id: int,
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
frame = db.get(Frame, frame_id)
|
||||
if frame is None:
|
||||
return _render_admin(request, db, admin, error="No such frame.")
|
||||
db.delete(frame) # links/battery log cascade
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin, notice=f"Frame #{frame_id} deleted.")
|
||||
Reference in New Issue
Block a user