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:
+163
-21
@@ -1,17 +1,24 @@
|
||||
"""Authentication dependencies.
|
||||
"""Authentication: password hashing, user sessions + CSRF, the legacy
|
||||
shared-token gate, and device resolution.
|
||||
|
||||
Phase A scope: browser routes keep the legacy shared-token gate
|
||||
(MANAGEMENT_TOKEN env var -- empty means open on a trusted LAN, exactly
|
||||
the old behavior), and device routes move to require_device, which
|
||||
already implements the full multi-frame resolution: per-frame device
|
||||
tokens, self-registration by device id, the legacy-token migration
|
||||
window, and pending-claim attachment. User sessions arrive in Phase B.
|
||||
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
|
||||
@@ -20,11 +27,133 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .db import get_db
|
||||
from .migration import new_device_token, new_manage_token
|
||||
from .models import Frame, PendingClaim, UserFrame
|
||||
from .models import Frame, PendingClaim, 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
|
||||
|
||||
# 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 _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 management_token() -> str:
|
||||
@@ -34,25 +163,38 @@ def management_token() -> str:
|
||||
|
||||
|
||||
def browser_token_valid(request: Request) -> bool:
|
||||
"""No MANAGEMENT_TOKEN configured means the web UI stays open on a
|
||||
trusted LAN, matching this project's original default. Once one's
|
||||
set, a request is authorized by either a ?token= query param or the
|
||||
cookie index() sets after a valid query-param hit (so the web UI's
|
||||
own fetch()/<img> calls, which carry no query string, stay authorized
|
||||
for the rest of that browsing visit)."""
|
||||
"""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 True
|
||||
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_access_token(request: Request) -> None:
|
||||
"""Dependency for the browser-facing /api/* routes (Phase A only --
|
||||
replaced by real sessions in Phase B). index() handles the
|
||||
unauthorized case itself with a friendlier HTML prompt."""
|
||||
if not browser_token_valid(request):
|
||||
raise HTTPException(401, "Missing or invalid access 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:
|
||||
|
||||
+37
-15
@@ -3,22 +3,30 @@ for the panel, and serves ESP32 frames ready-to-display images.
|
||||
|
||||
This module is assembly only -- routes live in app/routers/ (device.py
|
||||
for the firmware-facing /frame/* protocol, api.py for the web UI's
|
||||
/api/*), storage in SQLite via models.py/db.py, with migration.py
|
||||
importing a pre-database config.json deployment on first boot."""
|
||||
/api/*, pages.py for setup/login/settings/admin), storage in SQLite via
|
||||
models.py/db.py, with migration.py importing a pre-database config.json
|
||||
deployment on first boot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from . import migration
|
||||
from .auth import MANAGEMENT_TOKEN_COOKIE, browser_token_valid, management_token
|
||||
from .auth import (
|
||||
MANAGEMENT_TOKEN_COOKIE,
|
||||
browser_token_valid,
|
||||
current_session,
|
||||
current_user,
|
||||
management_token,
|
||||
users_exist,
|
||||
)
|
||||
from .db import SessionLocal
|
||||
from .quiet_hours import ALL_TIMEZONES
|
||||
from .routers import api, device
|
||||
from .routers import api, device, pages
|
||||
from .routers.common import default_frame, immich_creds
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -31,6 +39,7 @@ templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
app.include_router(device.router)
|
||||
app.include_router(api.router)
|
||||
app.include_router(pages.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -40,17 +49,28 @@ def health() -> dict:
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
"""The web UI (Phase A: still the single-frame page, bound to the
|
||||
default frame). Handles the unauthorized case itself with a friendly
|
||||
token prompt rather than a bare 401, since this is the one route a
|
||||
human lands on with no token yet."""
|
||||
if not browser_token_valid(request):
|
||||
supplied = request.query_params.get("token")
|
||||
return templates.TemplateResponse(
|
||||
"token_prompt.html", {"request": request, "wrong": supplied is not None}
|
||||
)
|
||||
|
||||
"""The web UI (still the single-frame page until Phase D). Access:
|
||||
a user session (normal path once /setup has run), the legacy shared
|
||||
token (the deployed frame's manage QR embeds ?token= -- kept working
|
||||
until Phase C replaces it with the limited manage page), or -- only
|
||||
while no users exist AND no token is configured -- fully open, the
|
||||
original trusted-LAN default."""
|
||||
with SessionLocal() as db:
|
||||
user = current_user(request, db)
|
||||
session = current_session(request, db) if user else None
|
||||
legacy_ok = browser_token_valid(request)
|
||||
|
||||
if user is None and not legacy_ok:
|
||||
if not users_exist(db):
|
||||
if management_token():
|
||||
supplied = request.query_params.get("token")
|
||||
return templates.TemplateResponse(
|
||||
"token_prompt.html", {"request": request, "wrong": supplied is not None}
|
||||
)
|
||||
# Fresh install, nothing configured: open, but nudge setup.
|
||||
return RedirectResponse("/setup", status_code=303)
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
frame = default_frame(db)
|
||||
immich_url, _ = immich_creds(frame)
|
||||
response = templates.TemplateResponse(
|
||||
@@ -60,6 +80,8 @@ def index(request: Request):
|
||||
"cfg": frame,
|
||||
"immich_url": immich_url,
|
||||
"timezones": ALL_TIMEZONES,
|
||||
"user": user,
|
||||
"csrf_token": session.csrf_token if session else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import gitea_releases, photo_queue, quiet_hours
|
||||
from ..auth import require_access_token
|
||||
from ..auth import require_browser
|
||||
from ..db import frame_locked, get_db
|
||||
from ..firmware import firmware_path, parse_app_version
|
||||
from ..models import BatteryLog, Frame
|
||||
@@ -32,7 +32,7 @@ from .common import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_access_token)])
|
||||
router = APIRouter(dependencies=[Depends(require_browser)])
|
||||
|
||||
MIN_REFRESH_INTERVAL_S = 60
|
||||
MAX_REFRESH_INTERVAL_S = 86400
|
||||
|
||||
@@ -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.")
|
||||
@@ -0,0 +1,100 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">Administration</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
|
||||
<div class="layout">
|
||||
<div class="main-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Users</h2>
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Username</th><th>Display name</th><th>Role</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{{ u.display_name }}</td>
|
||||
<td>{{ "admin" if u.is_admin else "user" }}</td>
|
||||
<td class="admin-actions">
|
||||
<details>
|
||||
<summary>Reset password</summary>
|
||||
<form method="post" action="/admin/users/{{ u.id }}/reset-password">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="password" name="password" minlength="8" placeholder="New password" required>
|
||||
<button type="submit" class="secondary btn-inline">Reset</button>
|
||||
</form>
|
||||
</details>
|
||||
{% if u.id != user.id %}
|
||||
<form method="post" action="/admin/users/{{ u.id }}/delete"
|
||||
onsubmit="return confirm('Delete user {{ u.username }}?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary btn-inline">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Enroll a user</h2>
|
||||
<form method="post" action="/admin/users">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Username
|
||||
<input type="text" name="username" maxlength="64" required>
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" minlength="8" required autocomplete="new-password">
|
||||
</label>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="is_admin" name="is_admin" value="true">
|
||||
<label for="is_admin">Administrator</label>
|
||||
</div>
|
||||
<button type="submit">Create user</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Frames</h2>
|
||||
{% for f in frames %}
|
||||
<div class="admin-frame">
|
||||
<p class="sub">
|
||||
<strong>#{{ f.id }} {{ f.name }}</strong><br>
|
||||
device: <code>{{ f.device_id or "not yet reported" }}</code><br>
|
||||
owner: {{ (f.owner.username if f.owner else none) or "UNCLAIMED" }}
|
||||
· linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br>
|
||||
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
|
||||
· token ack: {{ "yes" if f.device_token_ack else "no" }}
|
||||
{% if f.legacy_token_enabled %}· <strong>legacy token window OPEN</strong>{% endif %}
|
||||
</p>
|
||||
<form method="post" action="/admin/frames/{{ f.id }}/link-user" class="admin-inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="text" name="username" placeholder="Link user by name" required>
|
||||
<button type="submit" class="secondary btn-inline">Link</button>
|
||||
</form>
|
||||
{% if f.legacy_token_enabled %}
|
||||
<form method="post" action="/admin/frames/{{ f.id }}/end-legacy" class="admin-inline-form"
|
||||
onsubmit="return confirm('Close the legacy-token window for frame #{{ f.id }}? Only do this once the device has acknowledged its own token.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary btn-inline">Close legacy window</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/frames/{{ f.id }}/delete" class="admin-inline-form"
|
||||
onsubmit="return confirm('Delete frame #{{ f.id }} and all its history?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary btn-inline">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not frames %}<p class="sub">No frames yet.</p>{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -145,6 +145,35 @@
|
||||
.icon-btn:hover { background: var(--surface-alt); }
|
||||
.icon-btn:active { transform: scale(0.94); }
|
||||
|
||||
.topbar-actions { display: flex; align-items: center; gap: 14px; }
|
||||
.topnav { display: flex; align-items: center; gap: 14px; font-size: 13.5px; }
|
||||
.topnav a { color: var(--text-muted); text-decoration: none; }
|
||||
.topnav a:hover { color: var(--text); }
|
||||
.inline-form { display: inline; margin: 0; }
|
||||
button.linklike {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
}
|
||||
button.linklike:hover { color: var(--text); background: none; }
|
||||
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||
.admin-table th { text-align: left; color: var(--text-muted); font-weight: 600; padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border); }
|
||||
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
.admin-actions form { margin: 4px 0 0; }
|
||||
.admin-actions details summary { cursor: pointer; color: var(--text-muted); font-size: 13px; }
|
||||
.admin-actions input[type="password"] { margin-top: 6px; }
|
||||
.admin-frame { border-bottom: 1px solid var(--border); padding: 10px 0; }
|
||||
.admin-frame:last-child { border-bottom: none; }
|
||||
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
|
||||
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
|
||||
|
||||
h2.card-title, summary.card-title {
|
||||
font-size: 14.5px;
|
||||
font-weight: 650;
|
||||
@@ -319,12 +348,50 @@
|
||||
{% block subtitle %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
|
||||
<div class="topbar-actions">
|
||||
{% if user %}
|
||||
<nav class="topnav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/settings">Settings</a>
|
||||
{% if user.is_admin %}<a href="/admin">Admin</a>{% endif %}
|
||||
<form method="post" action="/logout" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="linklike">Log out</button>
|
||||
</form>
|
||||
</nav>
|
||||
{% endif %}
|
||||
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
{% if csrf_token %}
|
||||
<script>
|
||||
// Session-cookie auth needs CSRF proof on mutating requests. Rather
|
||||
// than touching every fetch() call site in the page scripts, wrap
|
||||
// fetch once: same-origin non-GET requests automatically carry the
|
||||
// per-session token. (Legacy shared-token access renders without a
|
||||
// csrf_token, so this block doesn't exist there at all.)
|
||||
(function () {
|
||||
var CSRF = {{ csrf_token | tojson }};
|
||||
var origFetch = window.fetch;
|
||||
window.fetch = function (input, init) {
|
||||
init = init || {};
|
||||
var method = (init.method || (input && input.method) || 'GET').toUpperCase();
|
||||
var url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
var sameOrigin = url.indexOf('://') === -1 || url.indexOf(location.origin) === 0;
|
||||
if (sameOrigin && method !== 'GET' && method !== 'HEAD') {
|
||||
init.headers = new Headers(init.headers || (input && input.headers) || {});
|
||||
init.headers.set('X-CSRF-Token', CSRF);
|
||||
}
|
||||
return origFetch.call(this, input, init);
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
// Shared theme toggle: explicit choice wins over the OS preference and
|
||||
// is remembered; with no explicit choice, the CSS above falls back to
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">Sign in</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h2 class="card-title">Log in</h2>
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<label>Username
|
||||
<input type="text" name="username" maxlength="64" required autofocus autocomplete="username">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">Your account</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if saved %}<div class="status ok">Saved.</div>{% endif %}
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Profile & photo library</h2>
|
||||
<form method="post" action="/settings">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Display name
|
||||
<input type="text" name="display_name" maxlength="64" value="{{ user.display_name }}">
|
||||
</label>
|
||||
<label>Immich URL
|
||||
<input type="text" name="immich_url" placeholder="http://your-immich-host:2283"
|
||||
value="{{ user.immich_url }}">
|
||||
</label>
|
||||
<label>Immich API key
|
||||
<input type="password" name="immich_api_key" autocomplete="off"
|
||||
placeholder="{% if user.immich_api_key %}(unchanged -- enter a new key to replace){% else %}your-immich-api-key{% endif %}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Frames you own pull photos from
|
||||
this Immich library. The key needs read access to albums/assets/faces
|
||||
plus <code>sharedLink.create</code> for the on-frame share QR.</p>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
|
||||
<label>Current password
|
||||
<input type="password" name="current_password" autocomplete="current-password">
|
||||
</label>
|
||||
<label>New password
|
||||
<input type="password" name="new_password" minlength="8" autocomplete="new-password">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Leave both blank to keep your
|
||||
current password.</p>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">First-run setup</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h2 class="card-title">Create the admin account</h2>
|
||||
<p class="sub">This server has no users yet. The account you create here
|
||||
is the administrator: it can enroll other users and manage every
|
||||
frame. Any frame this server already knows about is linked to it
|
||||
automatically.</p>
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="/setup">
|
||||
<label>Username
|
||||
<input type="text" name="username" maxlength="64" required autofocus autocomplete="username">
|
||||
</label>
|
||||
<label>Display name (optional)
|
||||
<input type="text" name="display_name" maxlength="64" autocomplete="name">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" minlength="8" required autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit">Create admin account</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user