Merge: promote dev to prod
This commit is contained in:
commit
05180afa80
17 changed files with 297 additions and 20 deletions
10
.env.example
10
.env.example
|
|
@ -31,6 +31,16 @@ ADMIN_EMAILS=
|
||||||
# Optional: origin of a separately-served frontend dev server (enables CORS). Leave empty in production.
|
# Optional: origin of a separately-served frontend dev server (enables CORS). Leave empty in production.
|
||||||
FRONTEND_ORIGIN=
|
FRONTEND_ORIGIN=
|
||||||
|
|
||||||
|
# Reverse-proxy trust for rate limiting. If you run the app behind a reverse proxy (Caddy/Nginx/
|
||||||
|
# Traefik…) that terminates TLS, set this to the IP the proxy CONNECTS FROM as the app sees it — the
|
||||||
|
# proxy's address on the app's network (e.g. the Docker/host/VPN IP), NOT the public client IP. Then
|
||||||
|
# the login/registration/password-reset rate limiters key on the real client (read from the proxy's
|
||||||
|
# X-Forwarded-For) instead of the proxy's own IP. Requests arriving from any OTHER peer (e.g. someone
|
||||||
|
# hitting the container port directly) are not trusted and are rate-limited by their real socket IP,
|
||||||
|
# so X-Forwarded-For can't be forged to dodge the limits. Comma-separated. Leave EMPTY if the app is
|
||||||
|
# directly exposed with no proxy (then the direct socket IP is used). Example: TRUSTED_PROXY_IPS=10.10.0.1
|
||||||
|
TRUSTED_PROXY_IPS=
|
||||||
|
|
||||||
# Optional YouTube Data API key (Google Cloud Console -> Credentials -> Create API key).
|
# Optional YouTube Data API key (Google Cloud Console -> Credentials -> Create API key).
|
||||||
# When set, all public reads (channels/videos/playlist backfill + enrichment) use the key
|
# When set, all public reads (channels/videos/playlist backfill + enrichment) use the key
|
||||||
# instead of a user's OAuth token, so 24/7 backfill never depends on a refresh token that
|
# instead of a user's OAuth token, so 24/7 backfill never depends on a refresh token that
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,8 @@ Port `8080` over plain HTTP is fine for a LAN or a quick trial. For public acces
|
||||||
proxy (Caddy, Nginx, Traefik…) in front to terminate TLS, and set the **public URL** (the installer
|
proxy (Caddy, Nginx, Traefik…) in front to terminate TLS, and set the **public URL** (the installer
|
||||||
prompt, or `OAUTH_REDIRECT_URL` in `.env`) to your `https://…` address — this also marks the session
|
prompt, or `OAUTH_REDIRECT_URL` in `.env`) to your `https://…` address — this also marks the session
|
||||||
cookie secure. Add that same `…/auth/callback` URL to your Google OAuth client's authorized redirect
|
cookie secure. Add that same `…/auth/callback` URL to your Google OAuth client's authorized redirect
|
||||||
URIs.
|
URIs. Behind a proxy, also set `TRUSTED_PROXY_IPS` so the rate limiters see the real client IP and
|
||||||
|
can't be bypassed via a forged `X-Forwarded-For` — see [docs/self-hosting.md](docs/self-hosting.md#https--public-access).
|
||||||
|
|
||||||
## Updating & backups
|
## Updating & backups
|
||||||
|
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.38.0
|
0.39.0
|
||||||
|
|
|
||||||
27
backend/alembic/versions/0053_user_session_epoch.py
Normal file
27
backend/alembic/versions/0053_user_session_epoch.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""User.session_epoch for server-side session revocation (SA4)
|
||||||
|
|
||||||
|
A monotonic per-user counter. A signed session cookie records the epoch it was minted under;
|
||||||
|
current_user rejects any cookie whose epoch is stale. Bumped on password reset, password change,
|
||||||
|
or "log out everywhere" — so a stolen/copied client-side cookie dies on those events instead of
|
||||||
|
staying valid until it expires. Existing rows default to 0 (matching a fresh cookie's absent/0 epoch).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0053_user_session_epoch"
|
||||||
|
down_revision: Union[str, None] = "0052_plex_show_meta"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column("session_epoch", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "session_epoch")
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
@ -172,13 +173,35 @@ def get_or_create_demo_user(db: Session) -> User:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_ip(s: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
||||||
|
"""Parse an IP for comparison, unwrapping IPv4-mapped IPv6 (`::ffff:10.0.0.1` → `10.0.0.1`) so a
|
||||||
|
plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string."""
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(s.strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(request: Request) -> str:
|
def _client_ip(request: Request) -> str:
|
||||||
"""Best-effort client IP for rate limiting. Behind our reverse proxy (Caddy/NPM) the
|
"""Best-effort client IP for rate limiting. X-Forwarded-For is client-controlled, so we trust it
|
||||||
real client is the first X-Forwarded-For hop; fall back to the socket peer otherwise."""
|
ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips
|
||||||
xff = request.headers.get("x-forwarded-for")
|
— the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS
|
||||||
if xff:
|
the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a
|
||||||
return xff.split(",")[0].strip()
|
client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the
|
||||||
return request.client.host if request.client else "unknown"
|
proxy) is untrusted — we use its real socket IP, so it can't spoof its rate-limit identity.
|
||||||
|
|
||||||
|
SAFETY: this relies on `request.client` being the TRUE socket peer. uvicorn is started WITHOUT
|
||||||
|
`--proxy-headers` (see entrypoint.sh) precisely so it never rewrites `request.client` from XFF —
|
||||||
|
do NOT add that flag, or the trust check below would compare against a forgeable value."""
|
||||||
|
peer = request.client.host if request.client else "unknown"
|
||||||
|
trusted = {_norm_ip(p) for p in settings.trusted_proxy_ip_set} - {None}
|
||||||
|
if _norm_ip(peer) in trusted:
|
||||||
|
xff = request.headers.get("x-forwarded-for")
|
||||||
|
parts = [p.strip() for p in (xff or "").split(",") if p.strip()]
|
||||||
|
if parts:
|
||||||
|
return parts[-1]
|
||||||
|
return peer
|
||||||
|
|
||||||
|
|
||||||
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||||
|
|
@ -341,6 +364,7 @@ async def callback(
|
||||||
accounts.append(user.id)
|
accounts.append(user.id)
|
||||||
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
|
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
|
||||||
request.session["user_id"] = user.id
|
request.session["user_id"] = user.id
|
||||||
|
_remember_epoch(request, user)
|
||||||
log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role)
|
log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role)
|
||||||
return RedirectResponse(url="/")
|
return RedirectResponse(url="/")
|
||||||
|
|
||||||
|
|
@ -493,6 +517,7 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -
|
||||||
# can't switch to / act as a previously signed-in account via the multi-account header.
|
# can't switch to / act as a previously signed-in account via the multi-account header.
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
request.session["user_id"] = demo.id
|
request.session["user_id"] = demo.id
|
||||||
|
_remember_epoch(request, demo)
|
||||||
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
|
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
|
||||||
return {"authenticated": True}
|
return {"authenticated": True}
|
||||||
return {"authenticated": False}
|
return {"authenticated": False}
|
||||||
|
|
@ -505,6 +530,7 @@ def _establish_session(request: Request, user: User) -> None:
|
||||||
accounts.append(user.id)
|
accounts.append(user.id)
|
||||||
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
|
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
|
||||||
request.session["user_id"] = user.id
|
request.session["user_id"] = user.id
|
||||||
|
_remember_epoch(request, user)
|
||||||
|
|
||||||
|
|
||||||
def _app_base() -> str:
|
def _app_base() -> str:
|
||||||
|
|
@ -587,8 +613,10 @@ def register(
|
||||||
upsert_pending_invite(db, email) # admin-approval gate
|
upsert_pending_invite(db, email) # admin-approval gate
|
||||||
if email_ok:
|
if email_ok:
|
||||||
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
||||||
|
# Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer.
|
||||||
|
# The SPA reads the fragment and POSTs it to /auth/verify (SB3).
|
||||||
background.add_task(
|
background.add_task(
|
||||||
email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}"
|
email_mod.send_verify_email, email, f"{_app_base()}/#verify={raw}"
|
||||||
)
|
)
|
||||||
if settings.admin_email_set:
|
if settings.admin_email_set:
|
||||||
background.add_task(
|
background.add_task(
|
||||||
|
|
@ -598,9 +626,22 @@ def register(
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify")
|
||||||
|
def verify_email_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
|
||||||
|
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
|
||||||
|
on any invalid/expired/used token."""
|
||||||
|
user = _consume_token(db, payload.get("token"), "verify")
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=400, detail="This verification link is invalid or has expired.")
|
||||||
|
user.email_verified = True
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/verify")
|
@router.get("/verify")
|
||||||
def verify_email(token: str, db: Session = Depends(get_db)):
|
def verify_email(token: str, db: Session = Depends(get_db)):
|
||||||
"""Confirm an email-verification link, then bounce to a friendly status on the app."""
|
"""Legacy query-token verification link (pre-SB3 emails still in flight). New emails use the
|
||||||
|
fragment + POST flow above; this bounces to a friendly status for older links until they expire."""
|
||||||
user = _consume_token(db, token, "verify")
|
user = _consume_token(db, token, "verify")
|
||||||
if user is None:
|
if user is None:
|
||||||
return RedirectResponse(url="/?verify=invalid")
|
return RedirectResponse(url="/?verify=invalid")
|
||||||
|
|
@ -662,7 +703,9 @@ def password_reset_request(
|
||||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||||
if user is not None and user.password_hash and not user.is_demo:
|
if user is not None and user.password_hash and not user.is_demo:
|
||||||
raw = _issue_token(db, user, "reset", RESET_TTL)
|
raw = _issue_token(db, user, "reset", RESET_TTL)
|
||||||
background.add_task(email_mod.send_password_reset, email, f"{_app_base()}/?reset={raw}")
|
# Token in the URL FRAGMENT (#), not the query (?): a fragment is never sent to the
|
||||||
|
# server, so it can't leak into proxy/access logs or a Referer header (SB3).
|
||||||
|
background.add_task(email_mod.send_password_reset, email, f"{_app_base()}/#reset={raw}")
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -680,6 +723,7 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
|
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
|
||||||
user.password_hash = hash_password(new_password)
|
user.password_hash = hash_password(new_password)
|
||||||
|
bump_session_epoch(user) # SA4: a reset (often a compromise response) kills all existing sessions.
|
||||||
# Burn any other outstanding reset tokens for this user.
|
# Burn any other outstanding reset tokens for this user.
|
||||||
for row in db.execute(
|
for row in db.execute(
|
||||||
select(AuthToken).where(
|
select(AuthToken).where(
|
||||||
|
|
@ -722,6 +766,21 @@ def resolved_user_id(request: Request) -> tuple[int | None, bool]:
|
||||||
return default_id, True
|
return default_id, True
|
||||||
|
|
||||||
|
|
||||||
|
def _remember_epoch(request: Request, user: User) -> None:
|
||||||
|
"""Record this account's current `session_epoch` in the signed cookie (SA4), so `current_user`
|
||||||
|
can later reject the cookie if the account's epoch was bumped (password reset/change, or
|
||||||
|
"log out everywhere"). Per-account so one account's revocation doesn't evict the others."""
|
||||||
|
epochs = dict(request.session.get("epochs") or {})
|
||||||
|
epochs[str(user.id)] = user.session_epoch
|
||||||
|
request.session["epochs"] = epochs
|
||||||
|
|
||||||
|
|
||||||
|
def bump_session_epoch(user: User) -> None:
|
||||||
|
"""Invalidate every EXISTING signed cookie for this account by advancing its epoch (SA4). Cookies
|
||||||
|
minted before the bump carry a now-stale epoch and are rejected by `current_user`."""
|
||||||
|
user.session_epoch = (user.session_epoch or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||||
user_id, is_default = resolved_user_id(request)
|
user_id, is_default = resolved_user_id(request)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
|
|
@ -734,6 +793,14 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||||
if is_default:
|
if is_default:
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
# SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a
|
||||||
|
# password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an
|
||||||
|
# account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too.
|
||||||
|
epochs = request.session.get("epochs") or {}
|
||||||
|
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
|
||||||
|
if is_default:
|
||||||
|
request.session.clear()
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
# Always keep the session default in the switchable list — covers sessions created before
|
# Always keep the session default in the switchable list — covers sessions created before
|
||||||
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
|
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
|
||||||
default_id = request.session.get("user_id")
|
default_id = request.session.get("user_id")
|
||||||
|
|
@ -756,6 +823,24 @@ def optional_current_user(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout-others")
|
||||||
|
def logout_others(
|
||||||
|
request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
"""SA4: sign every OTHER session for this account out — bump the epoch so all existing cookies
|
||||||
|
are rejected, then re-stamp THIS cookie so the caller stays signed in. For "I left myself logged
|
||||||
|
in somewhere" / a suspected session compromise. The demo account (shared) is excluded."""
|
||||||
|
if user.is_demo:
|
||||||
|
raise HTTPException(status_code=403, detail="Not available in the demo account.")
|
||||||
|
# Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current
|
||||||
|
# session at a newer epoch than the DB (see set_password).
|
||||||
|
bump_session_epoch(user)
|
||||||
|
db.commit()
|
||||||
|
_remember_epoch(request, user)
|
||||||
|
log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
def require_human(user: User = Depends(current_user)) -> User:
|
def require_human(user: User = Depends(current_user)) -> User:
|
||||||
"""Reject the shared demo account from actions that need a real Google/YouTube identity
|
"""Reject the shared demo account from actions that need a real Google/YouTube identity
|
||||||
or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has
|
or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has
|
||||||
|
|
@ -811,7 +896,10 @@ async def link_google(
|
||||||
|
|
||||||
@router.post("/set-password")
|
@router.post("/set-password")
|
||||||
def set_password(
|
def set_password(
|
||||||
payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)
|
payload: dict,
|
||||||
|
request: Request,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Set or change the signed-in account's password. Setting a first password (Google-only
|
"""Set or change the signed-in account's password. Setting a first password (Google-only
|
||||||
account) needs no current password — the session already proves identity. Changing an
|
account) needs no current password — the session already proves identity. Changing an
|
||||||
|
|
@ -828,7 +916,12 @@ def set_password(
|
||||||
if not verify_password(payload.get("current_password") or "", user.password_hash):
|
if not verify_password(payload.get("current_password") or "", user.password_hash):
|
||||||
raise HTTPException(status_code=403, detail="Current password is incorrect.")
|
raise HTTPException(status_code=403, detail="Current password is incorrect.")
|
||||||
user.password_hash = hash_password(new_password)
|
user.password_hash = hash_password(new_password)
|
||||||
|
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
|
||||||
|
# the person making the change stays signed in here. Commit BEFORE re-stamping so a failed commit
|
||||||
|
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
|
||||||
|
bump_session_epoch(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
_remember_epoch(request, user)
|
||||||
log.info("Password set/changed: uid=%s", user.id)
|
log.info("Password set/changed: uid=%s", user.id)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,14 @@ class Settings(BaseSettings):
|
||||||
# Origin of a separately served frontend dev server (enables CORS). Empty in production.
|
# Origin of a separately served frontend dev server (enables CORS). Empty in production.
|
||||||
frontend_origin: str = ""
|
frontend_origin: str = ""
|
||||||
|
|
||||||
|
# Reverse-proxy IPs whose X-Forwarded-For we trust for the real client IP (rate limiting). This
|
||||||
|
# is the address the proxy CONNECTS FROM as seen by the app — e.g. the WireGuard peer IP of the
|
||||||
|
# VPS Caddy front door. A request from any other peer (someone hitting the app port directly)
|
||||||
|
# is NOT trusted and its raw socket IP is used, so it can't spoof its rate-limit identity via
|
||||||
|
# XFF. Empty = trust no proxy (use the direct peer) — correct for a directly-exposed / local-dev
|
||||||
|
# deploy. Comma-separated. See auth._client_ip + the deploy docs.
|
||||||
|
trusted_proxy_ips: str = ""
|
||||||
|
|
||||||
# --- Outbound email (onboarding: access-request + approval notices) ---
|
# --- Outbound email (onboarding: access-request + approval notices) ---
|
||||||
# Gmail SMTP + App Password by default. All optional: if unset, email is skipped
|
# Gmail SMTP + App Password by default. All optional: if unset, email is skipped
|
||||||
# (fail-soft) and onboarding still works via in-app notifications.
|
# (fail-soft) and onboarding still works via in-app notifications.
|
||||||
|
|
@ -226,6 +234,10 @@ class Settings(BaseSettings):
|
||||||
def admin_email_set(self) -> set[str]:
|
def admin_email_set(self) -> set[str]:
|
||||||
return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()}
|
return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def trusted_proxy_ip_set(self) -> frozenset[str]:
|
||||||
|
return frozenset(p.strip() for p in self.trusted_proxy_ips.split(",") if p.strip())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def app_base(self) -> str:
|
def app_base(self) -> str:
|
||||||
"""Public origin of the deployed app, derived from the OAuth redirect URL
|
"""Public origin of the deployed app, derived from the OAuth redirect URL
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ class User(Base, TimestampMixin):
|
||||||
is_suspended: Mapped[bool] = mapped_column(
|
is_suspended: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false"
|
Boolean, default=False, server_default="false"
|
||||||
)
|
)
|
||||||
|
# Server-side session revocation (SA4): a monotonic counter bumped on password reset, password
|
||||||
|
# change, or "log out everywhere". A signed session cookie records the epoch it was minted under;
|
||||||
|
# current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those
|
||||||
|
# events (client-side signed cookies otherwise stay valid until they expire).
|
||||||
|
session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||||
|
|
|
||||||
|
|
@ -381,7 +381,13 @@ async def messages_ws(ws: WebSocket) -> None:
|
||||||
return
|
return
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ok = is_messageable_user(db.get(User, uid))
|
user = db.get(User, uid)
|
||||||
|
# SA4: honour server-side session revocation on the live channel too — reject a cookie whose
|
||||||
|
# recorded epoch is behind the account's current one (mirrors current_user). Without this a
|
||||||
|
# copied cookie could keep receiving pushes after a password reset / "log out everywhere".
|
||||||
|
epochs = sess.get("epochs") or {}
|
||||||
|
epoch_ok = user is not None and int(epochs.get(str(uid), 0)) == (user.session_epoch or 0)
|
||||||
|
ok = epoch_ok and is_messageable_user(user)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
if not ok:
|
if not ok:
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,9 @@ echo "Starting Siftlode API..."
|
||||||
# timeout, so Caddy would reuse a connection uvicorn had just closed -> broken pipe / reset
|
# timeout, so Caddy would reuse a connection uvicorn had just closed -> broken pipe / reset
|
||||||
# on the next request -> 502. Non-idempotent POSTs (the player's progress/state writes,
|
# on the next request -> 502. Non-idempotent POSTs (the player's progress/state writes,
|
||||||
# fired every 5s) can't be auto-retried by the proxy, so those 502s reached the user.
|
# fired every 5s) can't be auto-retried by the proxy, so those 502s reached the user.
|
||||||
|
#
|
||||||
|
# SECURITY: do NOT add --proxy-headers / --forwarded-allow-ips here. auth._client_ip trusts
|
||||||
|
# X-Forwarded-For only when the TRUE socket peer is a configured proxy (TRUSTED_PROXY_IPS); those
|
||||||
|
# flags would make uvicorn overwrite request.client from the forgeable XFF, defeating that check and
|
||||||
|
# reopening the rate-limit bypass (SA3).
|
||||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-config log_config.json --timeout-keep-alive 75
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-config log_config.json --timeout-keep-alive 75
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,21 @@ public access, put a reverse proxy (Caddy, Nginx, Traefik…) in front to termin
|
||||||
secure). If you've already run the installer, edit `OAUTH_REDIRECT_URL` in `.env` to the https
|
secure). If you've already run the installer, edit `OAUTH_REDIRECT_URL` in `.env` to the https
|
||||||
callback URL and `docker compose -f docker-compose.selfhost.yml up -d`.
|
callback URL and `docker compose -f docker-compose.selfhost.yml up -d`.
|
||||||
|
|
||||||
|
**Behind a proxy, set `TRUSTED_PROXY_IPS`.** The login / registration / password-reset rate limiters
|
||||||
|
identify callers by IP. Behind a proxy every request arrives from the proxy, so without this the
|
||||||
|
limits would apply to everyone together. Set `TRUSTED_PROXY_IPS` in `.env` to the address your proxy
|
||||||
|
connects to the app *from* (as the app sees it — e.g. its Docker/host/LAN IP, **not** the public
|
||||||
|
client address), and the app will read the real client IP from the proxy's `X-Forwarded-For` header:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TRUSTED_PROXY_IPS=172.18.0.1 # comma-separate multiple proxies
|
||||||
|
```
|
||||||
|
|
||||||
|
Only that peer is trusted, so a request sent straight to port `8080` (bypassing the proxy) can't forge
|
||||||
|
`X-Forwarded-For` to dodge the limits — it's rate-limited by its real address. Leave it empty if the
|
||||||
|
app is exposed directly with no proxy. Make sure your proxy actually sets `X-Forwarded-For` (Caddy's
|
||||||
|
`reverse_proxy` and Nginx's `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` both do).
|
||||||
|
|
||||||
## Download Center (media storage)
|
## Download Center (media storage)
|
||||||
|
|
||||||
The stack includes a **Download Center**: an admin-enabled feature that saves videos to the server
|
The stack includes a **Download Center**: an admin-enabled feature that saves videos to the server
|
||||||
|
|
|
||||||
|
|
@ -308,11 +308,36 @@ function AccessRow({
|
||||||
function SignInMethods({ me }: { me: Me }) {
|
function SignInMethods({ me }: { me: Me }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [current, setCurrent] = useState("");
|
const [current, setCurrent] = useState("");
|
||||||
const [next, setNext] = useState("");
|
const [next, setNext] = useState("");
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [sessBusy, setSessBusy] = useState(false);
|
||||||
|
|
||||||
|
// SA4: sign every OTHER session for this account out (this one stays). For a shared/left-behind
|
||||||
|
// device or a suspected compromise.
|
||||||
|
const logoutOthers = async () => {
|
||||||
|
if (
|
||||||
|
!(await confirm({
|
||||||
|
title: t("settings.account.sessions.confirmTitle"),
|
||||||
|
message: t("settings.account.sessions.confirmBody"),
|
||||||
|
confirmLabel: t("settings.account.sessions.action"),
|
||||||
|
danger: true,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setSessBusy(true);
|
||||||
|
try {
|
||||||
|
await api.logoutOthers();
|
||||||
|
notify({ level: "success", message: t("settings.account.sessions.done") });
|
||||||
|
} catch {
|
||||||
|
notify({ level: "error", message: t("settings.account.sessions.failed") });
|
||||||
|
} finally {
|
||||||
|
setSessBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -422,6 +447,24 @@ function SignInMethods({ me }: { me: Me }) {
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border mt-1 pt-1">
|
||||||
|
<div className="flex items-start justify-between gap-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium">{t("settings.account.sessions.title")}</div>
|
||||||
|
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
||||||
|
{t("settings.account.sessions.hint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logoutOthers}
|
||||||
|
disabled={sessBusy}
|
||||||
|
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t("settings.account.sessions.action")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
|
@ -259,22 +259,42 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
||||||
|
|
||||||
function AuthCard() {
|
function AuthCard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Snapshot the redirect params ONCE so we can clean them from the address bar below without
|
// Snapshot the pre-login params ONCE (query + fragment) so we can clean the address bar below
|
||||||
// dismissing the banners/reset-mode they drive (those read this stable snapshot, not the URL).
|
// without dismissing the banners/reset-mode they drive. Secret tokens (reset, verify) ride the
|
||||||
|
// URL FRAGMENT so they never reach the server / proxy logs / Referer (SB3); plain status flags
|
||||||
|
// stay in the query string.
|
||||||
const [params] = useState(() => new URLSearchParams(window.location.search));
|
const [params] = useState(() => new URLSearchParams(window.location.search));
|
||||||
const resetToken = params.get("reset");
|
const [hashParams] = useState(() => new URLSearchParams(window.location.hash.replace(/^#/, "")));
|
||||||
|
const resetToken = hashParams.get("reset");
|
||||||
|
const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow)
|
||||||
const bounced = params.get("access"); // Google denied → "requested" | "denied"
|
const bounced = params.get("access"); // Google denied → "requested" | "denied"
|
||||||
const verify = params.get("verify"); // "ok" | "invalid"
|
|
||||||
const login = params.get("login"); // Google login blocked → "suspended"
|
const login = params.get("login"); // Google login blocked → "suspended"
|
||||||
const deleted = params.get("deleted"); // self-service account deletion just completed
|
const deleted = params.get("deleted"); // self-service account deletion just completed
|
||||||
|
// Verification outcome: from POSTing the fragment token (new flow), or the legacy
|
||||||
|
// ?verify=ok|invalid redirect (pre-SB3 emails still in flight).
|
||||||
|
const [verify, setVerify] = useState<string | null>(() => params.get("verify"));
|
||||||
|
|
||||||
// Strip the query string so the address bar stays clean (http://host/) after we've captured it.
|
// Strip BOTH the query string and the fragment so the address bar stays clean and the token
|
||||||
|
// doesn't linger in history.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.location.search) {
|
if (window.location.search || window.location.hash) {
|
||||||
window.history.replaceState(null, "", window.location.pathname);
|
window.history.replaceState(null, "", window.location.pathname);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Confirm a fragment verification token by POSTing it (new SB3 flow). Fire ONCE: the token is
|
||||||
|
// single-use, so a double POST (StrictMode remount / any re-render) would 400 on the second call
|
||||||
|
// and wrongly flip the banner to "invalid".
|
||||||
|
const verifyFired = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!verifyToken || verifyFired.current) return;
|
||||||
|
verifyFired.current = true;
|
||||||
|
api
|
||||||
|
.verifyEmail(verifyToken)
|
||||||
|
.then(() => setVerify("ok"))
|
||||||
|
.catch(() => setVerify("invalid"));
|
||||||
|
}, [verifyToken]);
|
||||||
|
|
||||||
const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin");
|
const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,15 @@
|
||||||
"saving": "Speichern…",
|
"saving": "Speichern…",
|
||||||
"saved": "Passwort für {{email}} aktualisiert.",
|
"saved": "Passwort für {{email}} aktualisiert.",
|
||||||
"failed": "Das Passwort konnte nicht aktualisiert werden."
|
"failed": "Das Passwort konnte nicht aktualisiert werden."
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"title": "Aktive Sitzungen",
|
||||||
|
"hint": "Diese Kontoanmeldung überall sonst abmelden — praktisch, wenn du auf einem geteilten oder verlorenen Gerät angemeldet geblieben bist. Hier bleibst du angemeldet.",
|
||||||
|
"action": "Andere Sitzungen abmelden",
|
||||||
|
"confirmTitle": "Andere Sitzungen abmelden?",
|
||||||
|
"confirmBody": "Damit wirst du in allen anderen Browsern und auf allen anderen Geräten abgemeldet. Hier bleibst du angemeldet.",
|
||||||
|
"done": "Von allen anderen Sitzungen abgemeldet.",
|
||||||
|
"failed": "Andere Sitzungen konnten nicht abgemeldet werden. Bitte versuche es erneut."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,15 @@
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"saved": "Password updated for {{email}}.",
|
"saved": "Password updated for {{email}}.",
|
||||||
"failed": "Couldn't update the password."
|
"failed": "Couldn't update the password."
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"title": "Active sessions",
|
||||||
|
"hint": "Sign this account out everywhere else — handy if you left it signed in on a shared or lost device. You stay signed in here.",
|
||||||
|
"action": "Log out other sessions",
|
||||||
|
"confirmTitle": "Log out other sessions?",
|
||||||
|
"confirmBody": "This signs you out of every other browser and device. You'll stay signed in here.",
|
||||||
|
"done": "Signed out of all other sessions.",
|
||||||
|
"failed": "Couldn't sign out other sessions. Please try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,15 @@
|
||||||
"saving": "Mentés…",
|
"saving": "Mentés…",
|
||||||
"saved": "Jelszó frissítve ehhez: {{email}}.",
|
"saved": "Jelszó frissítve ehhez: {{email}}.",
|
||||||
"failed": "Nem sikerült frissíteni a jelszót."
|
"failed": "Nem sikerült frissíteni a jelszót."
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"title": "Aktív munkamenetek",
|
||||||
|
"hint": "Jelentkeztesd ki ezt a fiókot minden más helyen — hasznos, ha megosztott vagy elveszett eszközön maradt bejelentkezve. Itt bejelentkezve maradsz.",
|
||||||
|
"action": "Kijelentkezés máshonnan",
|
||||||
|
"confirmTitle": "Kijelentkezés a többi munkamenetből?",
|
||||||
|
"confirmBody": "Ez kijelentkeztet minden más böngészőből és eszközről. Itt bejelentkezve maradsz.",
|
||||||
|
"done": "Kijelentkeztetve minden más munkamenetből.",
|
||||||
|
"failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
|
|
|
||||||
|
|
@ -1391,6 +1391,11 @@ export const api = {
|
||||||
req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }),
|
req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }),
|
||||||
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
|
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
|
||||||
req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }),
|
req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }),
|
||||||
|
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
|
||||||
|
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
|
||||||
|
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
|
||||||
|
// SA4: sign every OTHER session for this account out (keeps the current one).
|
||||||
|
logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }),
|
||||||
// Set or change the signed-in account's password (errors shown inline via quiet).
|
// Set or change the signed-in account's password (errors shown inline via quiet).
|
||||||
setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
|
setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
|
||||||
req(
|
req(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,14 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.39.0",
|
||||||
|
date: "2026-07-12",
|
||||||
|
summary: "Sign-in security hardening.",
|
||||||
|
features: [
|
||||||
|
"New “Log out other sessions” button in Settings → Account, to sign out everywhere else.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.38.0",
|
version: "0.38.0",
|
||||||
date: "2026-07-12",
|
date: "2026-07-12",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue