security: harden data auth and deployment

This commit is contained in:
2026-09-17 18:31:35 +12:00
parent a6d1c73837
commit 5639dbcb83
32 changed files with 1401 additions and 378 deletions
+9 -11
View File
@@ -17,15 +17,15 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: "3.12" python-version: "3.12"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: "24" node-version: "24"
# Gitea cache restore/save stalls here; npm ci takes about 15 seconds. # Gitea cache restore/save stalls here; npm ci takes about 15 seconds.
@@ -47,7 +47,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Configure SSH key - name: Configure SSH key
env: env:
@@ -55,21 +55,20 @@ jobs:
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }} PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
run: | run: |
set -euo pipefail set -euo pipefail
: "${PROD_SSH_KNOWN_HOSTS:?PROD_SSH_KNOWN_HOSTS is required}"
mkdir -p ~/.ssh mkdir -p ~/.ssh
chmod 700 ~/.ssh chmod 700 ~/.ssh
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts chmod 644 ~/.ssh/known_hosts
fi
- name: Deploy to AMS-DEV01 - name: Deploy to AMS-DEV01
env: env:
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }} DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }} DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }} DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes
run: bash scripts/deploy_ams_dev01.sh run: bash scripts/deploy_ams_dev01.sh
deploy-beta: deploy-beta:
@@ -78,7 +77,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Configure SSH key - name: Configure SSH key
env: env:
@@ -86,19 +85,18 @@ jobs:
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }} PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
run: | run: |
set -euo pipefail set -euo pipefail
: "${PROD_SSH_KNOWN_HOSTS:?PROD_SSH_KNOWN_HOSTS is required}"
mkdir -p ~/.ssh mkdir -p ~/.ssh
chmod 700 ~/.ssh chmod 700 ~/.ssh
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts chmod 644 ~/.ssh/known_hosts
fi
- name: Deploy beta to AMS-DEV01 - name: Deploy beta to AMS-DEV01
env: env:
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }} DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }} DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }} PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes
run: bash scripts/deploy_beta_ams_dev01.sh run: bash scripts/deploy_beta_ams_dev01.sh
+1
View File
@@ -1,6 +1,7 @@
.env .env
bootstrap-admin.json bootstrap-admin.json
.venv/ .venv/
.security-test-venv*/
data/ data/
!data/branding/ !data/branding/
!data/branding/** !data/branding/**
+21 -11
View File
@@ -1,4 +1,4 @@
FROM node:24-slim AS frontend-builder FROM node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
@@ -13,11 +13,12 @@ COPY frontend/app ./app
COPY frontend/public ./public COPY frontend/public ./public
COPY frontend/next-env.d.ts ./next-env.d.ts COPY frontend/next-env.d.ts ./next-env.d.ts
COPY frontend/next.config.js ./next.config.js COPY frontend/next.config.js ./next.config.js
COPY frontend/proxy.ts ./proxy.ts
COPY frontend/tsconfig.json ./tsconfig.json COPY frontend/tsconfig.json ./tsconfig.json
RUN npm run build RUN npm run build
FROM python:3.14-slim FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6
WORKDIR /app WORKDIR /app
@@ -32,22 +33,31 @@ RUN apt-get update \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
ARG MAGENT_UID=1000
ARG MAGENT_GID=1000
RUN groupadd --gid ${MAGENT_GID} magent \
&& useradd --uid ${MAGENT_UID} --gid magent --create-home --shell /usr/sbin/nologin magent
COPY backend/requirements.txt . COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY backend/app ./app COPY --chown=magent:magent backend/app ./app
COPY data/branding /app/data/branding COPY --chown=magent:magent data/branding /app/data/branding
COPY --from=frontend-builder /frontend/.next /app/frontend/.next COPY --chown=magent:magent --from=frontend-builder /frontend/.next /app/frontend/.next
COPY --from=frontend-builder /frontend/public /app/frontend/public COPY --chown=magent:magent --from=frontend-builder /frontend/public /app/frontend/public
COPY --from=frontend-builder /frontend/node_modules /app/frontend/node_modules COPY --chown=magent:magent --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
COPY --from=frontend-builder /frontend/package.json /app/frontend/package.json COPY --chown=magent:magent --from=frontend-builder /frontend/package.json /app/frontend/package.json
COPY --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js COPY --chown=magent:magent --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
COPY --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts COPY --chown=magent:magent --from=frontend-builder /frontend/proxy.ts /app/frontend/proxy.ts
COPY --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json COPY --chown=magent:magent --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
COPY --chown=magent:magent --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
RUN chown -R magent:magent /app
USER magent:magent
EXPOSE 3000 8000 EXPOSE 3000 8000
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"] CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
+23 -5
View File
@@ -66,8 +66,9 @@ QBIT_URL="http://localhost:8080"
QBIT_USERNAME="..." QBIT_USERNAME="..."
QBIT_PASSWORD="..." QBIT_PASSWORD="..."
SQLITE_PATH="data/magent.db" SQLITE_PATH="data/magent.db"
JWT_SECRET="replace-with-a-long-random-secret" JWT_SECRET="replace-with-at-least-32-random-characters"
JWT_EXP_MINUTES="720" SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
JWT_EXP_MINUTES="120"
ADMIN_USERNAME="set-a-real-admin-username" ADMIN_USERNAME="set-a-real-admin-username"
ADMIN_PASSWORD="set-a-long-unique-admin-password" ADMIN_PASSWORD="set-a-long-unique-admin-password"
``` ```
@@ -114,8 +115,9 @@ $env:QBIT_URL="http://localhost:8080"
$env:QBIT_USERNAME="..." $env:QBIT_USERNAME="..."
$env:QBIT_PASSWORD="..." $env:QBIT_PASSWORD="..."
$env:SQLITE_PATH="data/magent.db" $env:SQLITE_PATH="data/magent.db"
$env:JWT_SECRET="replace-with-a-long-random-secret" $env:JWT_SECRET="replace-with-at-least-32-random-characters"
$env:JWT_EXP_MINUTES="720" $env:SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
$env:JWT_EXP_MINUTES="120"
$env:ADMIN_USERNAME="set-a-real-admin-username" $env:ADMIN_USERNAME="set-a-real-admin-username"
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password" $env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
``` ```
@@ -161,7 +163,23 @@ Configure these Gitea Actions secrets before enabling the deploy job:
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`. - `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
- `PROD_SSH_USER`: target user, for example `zak`. - `PROD_SSH_USER`: target user, for example `zak`.
- `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`. - `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`.
- `PROD_SSH_KNOWN_HOSTS`: optional pinned `known_hosts` entry for stricter host verification. - `PROD_SSH_KNOWN_HOSTS`: required pinned `known_hosts` entry. Deployments reject unknown or changed hosts.
## Security and data handling
Generate independent signing and settings-encryption secrets before first startup:
```bash
python -c "import secrets; print(secrets.token_urlsafe(48))"
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
- `JWT_SECRET` must contain at least 32 characters. Access sessions expire after 120 minutes by default and are revoked after logout, password, role, or blocked-state changes.
- `SETTINGS_ENCRYPTION_KEY` protects service API keys, SMTP credentials, webhooks, and private keys stored in SQLite. Keep it in `.env`, outside the database and its backups. If omitted, Magent derives a migration-compatible key from `JWT_SECRET`; a dedicated key is recommended.
- Invite secrets are stored as one-way hashes. Existing invite links continue to work after migration, but the admin UI cannot reveal an old link. Copy a link when it is created, or generate a replacement link later; replacement immediately invalidates the prior link.
- Magent encrypts sensitive settings, not the entire SQLite database. Request metadata, account records, logs, the `data/` volume, and backups should live on encrypted host storage with access restricted to the deployment account.
- `REQUESTS_CLEANUP_DAYS` controls routine request-history retention (90 days by default). Account deletion removes authentication and subscription records and anonymizes retained request and portal history.
- Production and beta cookies require HTTPS and use `SameSite=Strict`. Keep the backend port bound to loopback and publish the frontend only through the intended reverse proxy.
## History endpoints ## History endpoints
+4
View File
@@ -159,6 +159,9 @@ def _load_current_user_from_token(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if _is_expired(user.get("expires_at")): if _is_expired(user.get("expires_at")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
token_version = payload.get("ver")
if not isinstance(token_version, int) or token_version != int(user.get("auth_version") or 1):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session has been revoked")
user = normalize_user_auth_provider(user) user = normalize_user_auth_provider(user)
from .feature_access import permissions from .feature_access import permissions
@@ -183,6 +186,7 @@ def _load_current_user_from_token(
"is_expired": bool(user.get("is_expired", False)), "is_expired": bool(user.get("is_expired", False)),
"password_change_supported": bool(user.get("password_change_supported", False)), "password_change_supported": bool(user.get("password_change_supported", False)),
"password_provider": user.get("password_provider"), "password_provider": user.get("password_provider"),
"auth_version": int(user.get("auth_version") or 1),
} }
+7 -2
View File
@@ -24,7 +24,12 @@ class Settings(BaseSettings):
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE") default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
) )
jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET")) jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES")) jwt_exp_minutes: int = Field(default=120, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
jwt_issuer: str = Field(default="magent", validation_alias=AliasChoices("JWT_ISSUER"))
jwt_audience: str = Field(default="magent-web", validation_alias=AliasChoices("JWT_AUDIENCE"))
settings_encryption_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SETTINGS_ENCRYPTION_KEY")
)
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED")) api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
auth_rate_limit_window_seconds: int = Field( auth_rate_limit_window_seconds: int = Field(
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS") default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
@@ -53,7 +58,7 @@ class Settings(BaseSettings):
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE") default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
) )
auth_cookie_samesite: str = Field( auth_cookie_samesite: str = Field(
default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE") default="strict", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
) )
auth_cookie_domain: Optional[str] = Field( auth_cookie_domain: Optional[str] = Field(
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN") default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
+474 -68
View File
@@ -1,15 +1,18 @@
import json import json
import hmac
import os import os
import sqlite3 import sqlite3
import logging import logging
from contextlib import suppress
from hashlib import sha256 from hashlib import sha256
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from time import perf_counter from time import perf_counter, time as unix_time
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from .config import settings from .config import settings
from .models import Snapshot from .models import Snapshot
from .security import hash_password, verify_password from .security import hash_password, verify_and_update_password, verify_password
from .secret_storage import decrypt_setting_value, encrypt_setting_value, is_sensitive_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,7 +33,10 @@ def _db_path() -> str:
if not os.path.isabs(path): if not os.path.isabs(path):
app_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) app_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
path = os.path.join(app_root, path) path = os.path.join(app_root, path)
os.makedirs(os.path.dirname(path), exist_ok=True) directory = os.path.dirname(path)
os.makedirs(directory, exist_ok=True)
with suppress(OSError):
os.chmod(directory, 0o700)
return path return path
@@ -53,12 +59,23 @@ def _apply_connection_pragmas(conn: sqlite3.Connection) -> None:
logger.debug("sqlite pragma skipped: %s=%s", pragma, value, exc_info=True) logger.debug("sqlite pragma skipped: %s=%s", pragma, value, exc_info=True)
class _ClosingConnection(sqlite3.Connection):
def __exit__(self, exc_type, exc_value, traceback) -> bool:
try:
return super().__exit__(exc_type, exc_value, traceback)
finally:
self.close()
def _connect() -> sqlite3.Connection: def _connect() -> sqlite3.Connection:
conn = sqlite3.connect( conn = sqlite3.connect(
_db_path(), _db_path(),
timeout=SQLITE_BUSY_TIMEOUT_MS / 1000, timeout=SQLITE_BUSY_TIMEOUT_MS / 1000,
cached_statements=512, cached_statements=512,
factory=_ClosingConnection,
) )
with suppress(OSError):
os.chmod(_db_path(), 0o600)
_apply_connection_pragmas(conn) _apply_connection_pragmas(conn)
return conn return conn
@@ -185,6 +202,63 @@ def _has_secure_bootstrap_admin_credentials() -> bool:
return bool(password and password != _DEFAULT_ADMIN_PASSWORD) return bool(password and password != _DEFAULT_ADMIN_PASSWORD)
_INVITE_HASH_PREFIX = "sha256:"
def _normalize_invite_secret(value: str) -> str:
return "".join(character for character in str(value or "").strip().upper() if character.isalnum())
def _hash_signup_invite_code(value: str) -> str:
normalized = _normalize_invite_secret(value)
return _INVITE_HASH_PREFIX + sha256(normalized.encode("utf-8")).hexdigest()
def _invite_code_hint(value: str) -> str:
normalized = _normalize_invite_secret(value)
return normalized[-4:] if normalized else ""
def _masked_invite_code(hint: Optional[str]) -> str:
return f"••••{str(hint or '').upper()}" if hint else "Protected invite"
def _protect_legacy_signup_invite_codes(conn: sqlite3.Connection) -> None:
rows = conn.execute(
"SELECT id, code, code_hint FROM signup_invites ORDER BY id"
).fetchall()
for invite_id, stored_code, stored_hint in rows:
if not isinstance(stored_code, str) or stored_code.startswith(_INVITE_HASH_PREFIX):
continue
code_hash = _hash_signup_invite_code(stored_code)
duplicate = conn.execute(
"SELECT id FROM signup_invites WHERE code = ? AND id != ?",
(code_hash, invite_id),
).fetchone()
if duplicate:
code_hash = _INVITE_HASH_PREFIX + sha256(
f"duplicate:{invite_id}:{stored_code}".encode("utf-8")
).hexdigest()
conn.execute(
"UPDATE users SET invited_by_code = ? WHERE invited_by_code = ? COLLATE NOCASE",
(f"invite:{invite_id}", stored_code),
)
conn.execute(
"UPDATE signup_invites SET code = ?, code_hint = ? WHERE id = ?",
(code_hash, stored_hint or _invite_code_hint(stored_code), invite_id),
)
def _encrypt_legacy_sensitive_settings(conn: sqlite3.Connection) -> None:
rows = conn.execute("SELECT key, value FROM settings").fetchall()
for key, value in rows:
if value is None or not is_sensitive_setting(str(key)):
continue
encrypted = encrypt_setting_value(str(key), str(value))
if encrypted != value:
conn.execute("UPDATE settings SET value = ? WHERE key = ?", (encrypted, key))
def init_db() -> None: def init_db() -> None:
with _connect() as conn: with _connect() as conn:
conn.execute("CREATE TABLE IF NOT EXISTS request_stage_cache (request_id INTEGER PRIMARY KEY, source_updated TEXT, ready INTEGER NOT NULL, checked_at REAL NOT NULL)") conn.execute("CREATE TABLE IF NOT EXISTS request_stage_cache (request_id INTEGER PRIMARY KEY, source_updated TEXT, ready INTEGER NOT NULL, checked_at REAL NOT NULL)")
@@ -278,7 +352,8 @@ def init_db() -> None:
invited_by_code TEXT, invited_by_code TEXT,
invited_at TEXT, invited_at TEXT,
jellyfin_password_hash TEXT, jellyfin_password_hash TEXT,
last_jellyfin_auth_at TEXT last_jellyfin_auth_at TEXT,
auth_version INTEGER NOT NULL DEFAULT 1
) )
""" """
) )
@@ -297,6 +372,18 @@ def init_db() -> None:
) )
""" """
) )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS auth_rate_limits (
scope TEXT NOT NULL,
key_hash TEXT NOT NULL,
occurred_at REAL NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_auth_rate_limits_lookup ON auth_rate_limits (scope, key_hash, occurred_at)"
)
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS signup_invites ( CREATE TABLE IF NOT EXISTS signup_invites (
@@ -651,10 +738,20 @@ def init_db() -> None:
conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT") conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
try:
conn.execute("ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 1")
except sqlite3.OperationalError:
pass
try: try:
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT") conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
try:
conn.execute("ALTER TABLE signup_invites ADD COLUMN code_hint TEXT")
except sqlite3.OperationalError:
pass
_protect_legacy_signup_invite_codes(conn)
_encrypt_legacy_sensitive_settings(conn)
try: try:
conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER") conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER")
except sqlite3.OperationalError: except sqlite3.OperationalError:
@@ -1142,7 +1239,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled, created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users FROM users
WHERE username = ? COLLATE NOCASE WHERE username = ? COLLATE NOCASE
ORDER BY id ORDER BY id
@@ -1171,6 +1268,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"is_expired": _is_datetime_in_past(row[13]), "is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16], "jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17], "last_jellyfin_auth_at": row[17],
"auth_version": int(row[18] or 1),
} }
@@ -1181,7 +1279,7 @@ def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any
SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled, created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users FROM users
WHERE jellyseerr_user_id = ? WHERE jellyseerr_user_id = ?
ORDER BY id ASC ORDER BY id ASC
@@ -1211,6 +1309,7 @@ def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any
"is_expired": _is_datetime_in_past(row[13]), "is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16], "jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17], "last_jellyfin_auth_at": row[17],
"auth_version": int(row[18] or 1),
} }
@@ -1221,7 +1320,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled, created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users FROM users
WHERE id = ? WHERE id = ?
""", """,
@@ -1249,6 +1348,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
"is_expired": _is_datetime_in_past(row[13]), "is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16], "jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17], "last_jellyfin_auth_at": row[17],
"auth_version": int(row[18] or 1),
} }
def get_all_users() -> list[Dict[str, Any]]: def get_all_users() -> list[Dict[str, Any]]:
@@ -1257,7 +1357,7 @@ def get_all_users() -> list[Dict[str, Any]]:
""" """
SELECT id, username, email, role, auth_provider, jellyseerr_user_id, created_at, SELECT id, username, email, role, auth_provider, jellyseerr_user_id, created_at,
last_login_at, is_blocked, auto_search_enabled, invite_management_enabled, last_login_at, is_blocked, auto_search_enabled, invite_management_enabled,
profile_id, expires_at, invited_by_code, invited_at profile_id, expires_at, invited_by_code, invited_at, auth_version
FROM users FROM users
ORDER BY username COLLATE NOCASE ORDER BY username COLLATE NOCASE
""" """
@@ -1281,6 +1381,7 @@ def get_all_users() -> list[Dict[str, Any]]:
"expires_at": row[12], "expires_at": row[12],
"invited_by_code": row[13], "invited_by_code": row[13],
"invited_at": row[14], "invited_at": row[14],
"auth_version": int(row[15] or 1),
"is_expired": _is_datetime_in_past(row[12]), "is_expired": _is_datetime_in_past(row[12]),
} }
) )
@@ -1375,24 +1476,181 @@ def set_user_blocked(username: str, blocked: bool) -> None:
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE users SET is_blocked = ? WHERE username = ? UPDATE users SET is_blocked = ?, auth_version = auth_version + 1 WHERE username = ?
""", """,
(1 if blocked else 0, username), (1 if blocked else 0, username),
) )
logger.info("user blocked state updated username=%s blocked=%s", username, blocked) logger.info("user blocked state updated username=%s blocked=%s", username, blocked)
def delete_user_by_username(username: str) -> bool: def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
(table_name,),
).fetchone()
return bool(row)
def _redact_user_json(value: Any, identifiers: set[str]) -> Any:
if isinstance(value, dict):
return {key: _redact_user_json(item, identifiers) for key, item in value.items()}
if isinstance(value, list):
return [_redact_user_json(item, identifiers) for item in value]
if isinstance(value, str) and value.strip().casefold() in identifiers:
return "Deleted user"
return value
def delete_user_data_by_username(username: str) -> Dict[str, int | bool]:
with _connect() as conn: with _connect() as conn:
cursor = conn.execute( user = conn.execute(
""" "SELECT id, username, email FROM users WHERE username = ? COLLATE NOCASE",
DELETE FROM users WHERE username = ? COLLATE NOCASE
""",
(username,), (username,),
).fetchone()
if not user:
return {"deleted": False}
user_id, canonical_username, email = int(user[0]), str(user[1]), user[2]
pseudonym = f"deleted-user-{user_id}"
identifiers = {canonical_username.casefold()}
if isinstance(email, str) and email.strip():
identifiers.add(email.strip().casefold())
counts: Dict[str, int | bool] = {"deleted": False}
request_rows = conn.execute(
"""
SELECT request_id, payload_json FROM requests_cache
WHERE requested_by_id = ? OR requested_by_norm = ? OR requested_by = ? COLLATE NOCASE
""",
(user_id, canonical_username.casefold(), canonical_username),
).fetchall()
for request_id, payload_json in request_rows:
try:
payload = _redact_user_json(json.loads(payload_json), identifiers)
sanitized_payload = json.dumps(payload, separators=(",", ":"))
except (TypeError, json.JSONDecodeError):
sanitized_payload = "{}"
conn.execute(
"""
UPDATE requests_cache
SET requested_by = 'Deleted user', requested_by_norm = NULL,
requested_by_id = NULL, payload_json = ?
WHERE request_id = ?
""",
(sanitized_payload, request_id),
) )
deleted = cursor.rowcount > 0 snapshot_rows = conn.execute(
logger.warning("user delete username=%s deleted=%s", username, deleted) "SELECT id, payload_json FROM snapshots WHERE request_id = ?",
return deleted (str(request_id),),
).fetchall()
for snapshot_id, snapshot_json in snapshot_rows:
try:
snapshot_payload = _redact_user_json(
json.loads(snapshot_json), identifiers
)
sanitized_snapshot = json.dumps(
snapshot_payload, separators=(",", ":")
)
except (TypeError, json.JSONDecodeError):
sanitized_snapshot = "{}"
conn.execute(
"UPDATE snapshots SET payload_json = ? WHERE id = ?",
(sanitized_snapshot, snapshot_id),
)
conn.execute(
"UPDATE actions SET message = REPLACE(message, ?, 'Deleted user') WHERE request_id = ? AND message IS NOT NULL",
(canonical_username, str(request_id)),
)
if email:
conn.execute(
"UPDATE actions SET message = REPLACE(message, ?, '[deleted email]') WHERE request_id = ? AND message IS NOT NULL",
(email, str(request_id)),
)
counts["requests_anonymized"] = len(request_rows)
direct_operations = (
("DELETE FROM user_activity WHERE username = ? COLLATE NOCASE", (canonical_username,), "activity_deleted"),
("DELETE FROM password_reset_tokens WHERE username = ? COLLATE NOCASE", (canonical_username,), "reset_tokens_deleted"),
("DELETE FROM user_feature_permissions WHERE user_id = ?", (user_id,), "feature_rows_deleted"),
("DELETE FROM jellyfin_user_links WHERE local_user_id = ?", (user_id,), "identity_links_deleted"),
("DELETE FROM user_identity_confirmations WHERE local_user_id = ?", (user_id,), "identity_confirmations_deleted"),
("DELETE FROM user_identity_repairs WHERE local_user_id = ?", (user_id,), "identity_repairs_deleted"),
("DELETE FROM user_duplicate_repairs WHERE kept_user_id = ?", (user_id,), "duplicate_repairs_deleted"),
)
for sql, params, label in direct_operations:
counts[label] = int(conn.execute(sql, params).rowcount or 0)
conn.execute(
"UPDATE signup_invites SET enabled = 0, created_by = ? WHERE created_by = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
if email:
conn.execute(
"UPDATE signup_invites SET recipient_email = NULL WHERE recipient_email = ? COLLATE NOCASE",
(email,),
)
conn.execute(
"UPDATE portal_items SET created_by_username = ?, created_by_id = NULL WHERE created_by_id = ? OR created_by_username = ? COLLATE NOCASE",
(pseudonym, user_id, canonical_username),
)
conn.execute(
"UPDATE portal_items SET assignee_username = NULL WHERE assignee_username = ? COLLATE NOCASE",
(canonical_username,),
)
conn.execute(
"UPDATE portal_comments SET author_username = ? WHERE author_username = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
conn.execute(
"UPDATE portal_item_activity SET actor_username = ? WHERE actor_username = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
conn.execute(
"UPDATE user_identity_confirmations SET confirmed_by = ? WHERE confirmed_by = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
conn.execute(
"UPDATE user_identity_repairs SET repaired_by = ? WHERE repaired_by = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
duplicate_rows = conn.execute(
"SELECT id, archive_json FROM user_duplicate_repairs"
).fetchall()
for repair_id, archive_json in duplicate_rows:
try:
archive_payload = _redact_user_json(
json.loads(archive_json), identifiers
)
except (TypeError, json.JSONDecodeError):
continue
conn.execute(
"UPDATE user_duplicate_repairs SET archive_json = ?, repaired_by = CASE WHEN repaired_by = ? COLLATE NOCASE THEN ? ELSE repaired_by END WHERE id = ?",
(
json.dumps(archive_payload, separators=(",", ":")),
canonical_username,
pseudonym,
repair_id,
),
)
for table in ("email_recap_subscriptions", "email_recap_deliveries", "newsletter_subscriptions", "newsletter_deliveries"):
if _table_exists(conn, table):
counts[f"{table}_deleted"] = int(
conn.execute(f"DELETE FROM {table} WHERE user_id = ?", (user_id,)).rowcount or 0
)
if _table_exists(conn, "newsletter_editions"):
conn.execute(
"UPDATE newsletter_editions SET created_by = ? WHERE created_by = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
deleted = conn.execute("DELETE FROM users WHERE id = ?", (user_id,)).rowcount > 0
counts["deleted"] = deleted
logger.warning("user data deleted user_id=%s deleted=%s", user_id, deleted)
return counts
def delete_user_by_username(username: str) -> bool:
return bool(delete_user_data_by_username(username).get("deleted"))
def delete_user_activity_by_username(username: str) -> int: def delete_user_activity_by_username(username: str) -> int:
@@ -1424,7 +1682,7 @@ def set_user_role(username: str, role: str) -> None:
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE users SET role = ? WHERE username = ? COLLATE NOCASE UPDATE users SET role = ?, auth_version = auth_version + 1 WHERE username = ? COLLATE NOCASE
""", """,
(role, username), (role, username),
) )
@@ -1635,29 +1893,31 @@ def delete_user_profile(profile_id: int) -> bool:
def _row_to_signup_invite(row: Any) -> Dict[str, Any]: def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
max_uses = 1 if row[10] else row[6] max_uses = 1 if row[11] else row[7]
use_count = int(row[7] or 0) use_count = int(row[8] or 0)
expires_at = row[9] expires_at = row[10]
is_expired = _is_datetime_in_past(expires_at) is_expired = _is_datetime_in_past(expires_at)
remaining_uses = None if max_uses is None else max(int(max_uses) - use_count, 0) remaining_uses = None if max_uses is None else max(int(max_uses) - use_count, 0)
return { return {
"id": row[0], "id": row[0],
"code": row[1], "code": _masked_invite_code(row[2]),
"label": row[2], "code_hint": row[2],
"description": row[3], "code_available": False,
"profile_id": row[4], "label": row[3],
"role": row[5], "description": row[4],
"profile_id": row[5],
"role": row[6],
"max_uses": max_uses, "max_uses": max_uses,
"use_count": use_count, "use_count": use_count,
"enabled": bool(row[8]), "enabled": bool(row[9]),
"expires_at": expires_at, "expires_at": expires_at,
"recipient_email": row[10], "recipient_email": row[11],
"created_by": row[11], "created_by": row[12],
"created_at": row[12], "created_at": row[13],
"updated_at": row[13], "updated_at": row[14],
"is_expired": is_expired, "is_expired": is_expired,
"remaining_uses": remaining_uses, "remaining_uses": remaining_uses,
"is_usable": bool(row[8]) and not is_expired and (remaining_uses is None or remaining_uses > 0), "is_usable": bool(row[9]) and not is_expired and (remaining_uses is None or remaining_uses > 0),
} }
@@ -1665,7 +1925,7 @@ def list_signup_invites() -> list[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, SELECT id, code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, recipient_email, created_by, created_at, updated_at expires_at, recipient_email, created_by, created_at, updated_at
FROM signup_invites FROM signup_invites
ORDER BY created_at DESC, id DESC ORDER BY created_at DESC, id DESC
@@ -1678,7 +1938,7 @@ def get_signup_invite_by_id(invite_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
row = conn.execute( row = conn.execute(
""" """
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, SELECT id, code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, recipient_email, created_by, created_at, updated_at expires_at, recipient_email, created_by, created_at, updated_at
FROM signup_invites FROM signup_invites
WHERE id = ? WHERE id = ?
@@ -1694,16 +1954,19 @@ def get_signup_invite_by_code(code: str) -> Optional[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
row = conn.execute( row = conn.execute(
""" """
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, SELECT id, code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, recipient_email, created_by, created_at, updated_at expires_at, recipient_email, created_by, created_at, updated_at
FROM signup_invites FROM signup_invites
WHERE code = ? COLLATE NOCASE WHERE code = ?
""", """,
(code,), (_hash_signup_invite_code(code),),
).fetchone() ).fetchone()
if not row: if not row:
return None return None
return _row_to_signup_invite(row) invite = _row_to_signup_invite(row)
invite["code"] = _normalize_invite_secret(code)
invite["code_available"] = True
return invite
def create_signup_invite( def create_signup_invite(
@@ -1719,6 +1982,9 @@ def create_signup_invite(
recipient_email: Optional[str] = None, recipient_email: Optional[str] = None,
created_by: Optional[str] = None, created_by: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
normalized_code = _normalize_invite_secret(code)
if not normalized_code:
raise ValueError("Invite code is required")
if recipient_email: if recipient_email:
max_uses = 1 max_uses = 1
timestamp = datetime.now(timezone.utc).isoformat() timestamp = datetime.now(timezone.utc).isoformat()
@@ -1726,13 +1992,14 @@ def create_signup_invite(
cursor = conn.execute( cursor = conn.execute(
""" """
INSERT INTO signup_invites ( INSERT INTO signup_invites (
code, label, description, profile_id, role, max_uses, use_count, enabled, code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, recipient_email, created_by, created_at, updated_at expires_at, recipient_email, created_by, created_at, updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
""", """,
( (
code, _hash_signup_invite_code(normalized_code),
_invite_code_hint(normalized_code),
label, label,
description, description,
profile_id, profile_id,
@@ -1748,20 +2015,21 @@ def create_signup_invite(
) )
invite_id = int(cursor.lastrowid) invite_id = int(cursor.lastrowid)
logger.info( logger.info(
"signup invite created invite_id=%s code=%s role=%s profile_id=%s max_uses=%s enabled=%s expires_at=%s recipient_email=%s created_by=%s", "signup invite created invite_id=%s role=%s profile_id=%s max_uses=%s enabled=%s expires_at=%s has_recipient=%s created_by=%s",
invite_id, invite_id,
code,
role, role,
profile_id, profile_id,
max_uses, max_uses,
enabled, enabled,
expires_at, expires_at,
recipient_email, bool(recipient_email),
created_by, created_by,
) )
invite = get_signup_invite_by_id(invite_id) invite = get_signup_invite_by_id(invite_id)
if not invite: if not invite:
raise RuntimeError("Invite creation failed") raise RuntimeError("Invite creation failed")
invite["code"] = normalized_code
invite["code_available"] = True
return invite return invite
@@ -1784,31 +2052,68 @@ def update_signup_invite(
if existing and existing.get('recipient_email') and int(existing.get('use_count') or 0) > 0 and recipient_email != existing.get('recipient_email'): if existing and existing.get('recipient_email') and int(existing.get('use_count') or 0) > 0 and recipient_email != existing.get('recipient_email'):
raise ValueError('A used email invitation cannot be reassigned.') raise ValueError('A used email invitation cannot be reassigned.')
timestamp = datetime.now(timezone.utc).isoformat() timestamp = datetime.now(timezone.utc).isoformat()
requested_code = str(code or "").strip()
rotate_code = bool(requested_code) and not requested_code.startswith("••••") and requested_code != "Protected invite"
with _connect() as conn: with _connect() as conn:
if rotate_code:
normalized_code = _normalize_invite_secret(requested_code)
cursor = conn.execute( cursor = conn.execute(
""" """
UPDATE signup_invites UPDATE signup_invites
SET code = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?, SET code = ?, code_hint = ?, label = ?, description = ?, profile_id = ?, role = ?,
max_uses = ?, enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ?
WHERE id = ?
""",
(
_hash_signup_invite_code(normalized_code), _invite_code_hint(normalized_code),
label, description, profile_id, role, max_uses, 1 if enabled else 0,
expires_at, recipient_email, timestamp, invite_id,
),
)
else:
cursor = conn.execute(
"""
UPDATE signup_invites
SET label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?,
enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ? enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ?
WHERE id = ? WHERE id = ?
""", """,
( (
code, label, description, profile_id, role, max_uses, 1 if enabled else 0,
label, expires_at, recipient_email, timestamp, invite_id,
description, ),
profile_id, )
role, if cursor.rowcount <= 0:
max_uses, return None
1 if enabled else 0, return get_signup_invite_by_id(invite_id)
expires_at,
recipient_email,
def rotate_signup_invite_code(invite_id: int, code: str) -> Optional[Dict[str, Any]]:
normalized_code = _normalize_invite_secret(code)
if not normalized_code:
raise ValueError("Invite code is required")
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
UPDATE signup_invites
SET code = ?, code_hint = ?, updated_at = ?
WHERE id = ? AND enabled = 1
""",
(
_hash_signup_invite_code(normalized_code),
_invite_code_hint(normalized_code),
timestamp, timestamp,
invite_id, invite_id,
), ),
) )
if cursor.rowcount <= 0: if cursor.rowcount <= 0:
return None return None
return get_signup_invite_by_id(invite_id) invite = get_signup_invite_by_id(invite_id)
if invite:
invite["code"] = normalized_code
invite["code_available"] = True
return invite
def delete_signup_invite(invite_id: int) -> bool: def delete_signup_invite(invite_id: int) -> bool:
@@ -1859,7 +2164,7 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled, created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users FROM users
WHERE username = ? COLLATE NOCASE WHERE username = ? COLLATE NOCASE
ORDER BY ORDER BY
@@ -1874,8 +2179,15 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any
provider = str(row[4] or "local").lower() provider = str(row[4] or "local").lower()
if provider != "local": if provider != "local":
continue continue
if not verify_password(password, row[2]): verified, updated_hash = verify_and_update_password(password, row[2])
if not verified:
continue continue
if updated_hash:
with _connect() as conn:
conn.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(updated_hash, row[0]),
)
return { return {
"id": row[0], "id": row[0],
"username": row[1], "username": row[1],
@@ -1895,6 +2207,7 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any
"is_expired": _is_datetime_in_past(row[12]), "is_expired": _is_datetime_in_past(row[12]),
"jellyfin_password_hash": row[15], "jellyfin_password_hash": row[15],
"last_jellyfin_auth_at": row[16], "last_jellyfin_auth_at": row[16],
"auth_version": int(row[17] or 1),
} }
return None return None
@@ -1906,7 +2219,7 @@ def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]:
SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled, created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users FROM users
WHERE username = ? COLLATE NOCASE WHERE username = ? COLLATE NOCASE
ORDER BY ORDER BY
@@ -1938,6 +2251,7 @@ def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]:
"is_expired": _is_datetime_in_past(row[13]), "is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16], "jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17], "last_jellyfin_auth_at": row[17],
"auth_version": int(row[18] or 1),
} }
) )
return results return results
@@ -1956,7 +2270,7 @@ def set_user_email(username: str, email: Optional[str]) -> bool:
) )
updated = cursor.rowcount > 0 updated = cursor.rowcount > 0
if updated: if updated:
logger.info("user email updated username=%s email=%s", username, normalized_email) logger.info("user email updated username=%s email_set=%s", username, bool(normalized_email))
else: else:
logger.debug("user email update skipped username=%s", username) logger.debug("user email update skipped username=%s", username)
return updated return updated
@@ -1967,12 +2281,74 @@ def set_user_password(username: str, password: str) -> None:
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE users SET password_hash = ? WHERE username = ? COLLATE NOCASE UPDATE users
SET password_hash = ?, auth_version = auth_version + 1
WHERE username = ? COLLATE NOCASE
""", """,
(password_hash, username), (password_hash, username),
) )
def increment_user_auth_version(username: str) -> int:
with _connect() as conn:
conn.execute(
"UPDATE users SET auth_version = auth_version + 1 WHERE username = ? COLLATE NOCASE",
(username,),
)
row = conn.execute(
"SELECT auth_version FROM users WHERE username = ? COLLATE NOCASE",
(username,),
).fetchone()
return int(row[0] or 1) if row else 0
def _rate_limit_key_hash(key: str) -> str:
key_material = str(
settings.jwt_secret or settings.settings_encryption_key or "magent-rate-limit"
).encode("utf-8")
return hmac.new(
key_material, str(key or "").encode("utf-8"), sha256
).hexdigest()
def get_rate_limit_status(
scope: str, key: str, window_seconds: int, maximum: int
) -> tuple[bool, int]:
now = unix_time()
cutoff = now - max(1, int(window_seconds))
key_hash = _rate_limit_key_hash(key)
with _connect() as conn:
conn.execute("DELETE FROM auth_rate_limits WHERE occurred_at < ?", (cutoff,))
row = conn.execute(
"""
SELECT COUNT(*), MIN(occurred_at)
FROM auth_rate_limits
WHERE scope = ? AND key_hash = ? AND occurred_at >= ?
""",
(scope, key_hash, cutoff),
).fetchone()
count = int((row or [0])[0] or 0)
oldest = float(row[1]) if row and row[1] is not None else now
retry_after = max(1, int(window_seconds - (now - oldest)))
return count >= max(1, int(maximum)), retry_after
def record_rate_limit_event(scope: str, key: str) -> None:
with _connect() as conn:
conn.execute(
"INSERT INTO auth_rate_limits (scope, key_hash, occurred_at) VALUES (?, ?, ?)",
(scope, _rate_limit_key_hash(key), unix_time()),
)
def clear_rate_limit_events(scope: str, key: str) -> None:
with _connect() as conn:
conn.execute(
"DELETE FROM auth_rate_limits WHERE scope = ? AND key_hash = ?",
(scope, _rate_limit_key_hash(key)),
)
def sync_jellyfin_password_state(username: str, password: str) -> None: def sync_jellyfin_password_state(username: str, password: str) -> None:
if not username or not password: if not username or not password:
return return
@@ -2943,11 +3319,12 @@ def get_setting(key: str) -> Optional[str]:
).fetchone() ).fetchone()
if not row: if not row:
return None return None
return row[0] return decrypt_setting_value(key, row[0])
def set_setting(key: str, value: Optional[str]) -> None: def set_setting(key: str, value: Optional[str]) -> None:
updated_at = datetime.now(timezone.utc).isoformat() updated_at = datetime.now(timezone.utc).isoformat()
stored_value = encrypt_setting_value(key, value)
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(
""" """
@@ -2955,7 +3332,7 @@ def set_setting(key: str, value: Optional[str]) -> None:
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
""", """,
(key, value, updated_at), (key, stored_value, updated_at),
) )
@@ -2981,7 +3358,7 @@ def get_settings_overrides() -> Dict[str, str]:
key = row[0] key = row[0]
value = row[1] value = row[1]
if key: if key:
overrides[key] = value overrides[key] = decrypt_setting_value(key, value)
return overrides return overrides
@@ -3067,12 +3444,10 @@ def create_password_reset_token(
), ),
) )
logger.info( logger.info(
"password reset token created username=%s provider=%s recipient=%s expires_at=%s requester_ip=%s", "password reset token created username=%s provider=%s expires_at=%s",
username, username,
auth_provider, auth_provider,
recipient_email,
expires_at, expires_at,
requested_by_ip,
) )
return { return {
"username": username, "username": username,
@@ -3114,7 +3489,7 @@ def mark_password_reset_token_used(token_value: str) -> None:
""", """,
(used_at, token_hash), (used_at, token_hash),
) )
logger.info("password reset token marked used token_hash=%s", token_hash[:12]) logger.info("password reset token marked used")
def get_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> Optional[Dict[str, Any]]: def get_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> Optional[Dict[str, Any]]:
@@ -4020,6 +4395,7 @@ def cleanup_history(days: int) -> Dict[str, int]:
if days <= 0: if days <= 0:
return {"actions": 0, "snapshots": 0} return {"actions": 0, "snapshots": 0}
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
cutoff_epoch = (datetime.now(timezone.utc) - timedelta(days=days)).timestamp()
with _connect() as conn: with _connect() as conn:
actions = conn.execute( actions = conn.execute(
"DELETE FROM actions WHERE created_at < ?", "DELETE FROM actions WHERE created_at < ?",
@@ -4029,7 +4405,37 @@ def cleanup_history(days: int) -> Dict[str, int]:
"DELETE FROM snapshots WHERE created_at < ?", "DELETE FROM snapshots WHERE created_at < ?",
(cutoff,), (cutoff,),
).rowcount ).rowcount
return {"actions": actions, "snapshots": snapshots} reset_tokens = conn.execute(
"DELETE FROM password_reset_tokens WHERE expires_at < ? OR (used_at IS NOT NULL AND used_at < ?)",
(cutoff, cutoff),
).rowcount
invites = conn.execute(
"""
DELETE FROM signup_invites
WHERE updated_at < ?
AND (enabled = 0 OR expires_at < ? OR (max_uses IS NOT NULL AND use_count >= max_uses))
AND id != COALESCE((SELECT CAST(value AS INTEGER) FROM settings WHERE key = 'self_service_invite_master_id'), -1)
""",
(cutoff, cutoff),
).rowcount
rate_limits = conn.execute(
"DELETE FROM auth_rate_limits WHERE occurred_at < ?",
(unix_time() - 86400,),
).rowcount
email_deliveries = 0
for table in ("email_recap_deliveries", "newsletter_deliveries"):
if _table_exists(conn, table):
email_deliveries += int(
conn.execute(f"DELETE FROM {table} WHERE created_at < ?", (cutoff_epoch,)).rowcount or 0
)
return {
"actions": int(actions or 0),
"snapshots": int(snapshots or 0),
"password_reset_tokens": int(reset_tokens or 0),
"invites": int(invites or 0),
"rate_limits": int(rate_limits or 0),
"email_deliveries": email_deliveries,
}
def get_request_stage_cache(): def get_request_stage_cache():
+16 -4
View File
@@ -2,6 +2,7 @@ import contextvars
import json import json
import logging import logging
import os import os
import re
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from typing import Any, Mapping, Optional from typing import Any, Mapping, Optional
from urllib.parse import parse_qs from urllib.parse import parse_qs
@@ -27,6 +28,9 @@ _SENSITIVE_KEYWORDS = (
"token", "token",
) )
_MAX_BODY_BYTES = 4096 _MAX_BODY_BYTES = 4096
_SENSITIVE_PATH_PATTERNS = (
re.compile(r"(/auth/invites/)[^/]+", re.IGNORECASE),
)
class RequestContextFilter(logging.Filter): class RequestContextFilter(logging.Filter):
@@ -47,6 +51,13 @@ def current_request_id() -> str:
return REQUEST_ID_CONTEXT.get("-") return REQUEST_ID_CONTEXT.get("-")
def sanitize_path(path: str) -> str:
sanitized = str(path or "")
for pattern in _SENSITIVE_PATH_PATTERNS:
sanitized = pattern.sub(r"\1[REDACTED]", sanitized)
return sanitized
def _is_sensitive_key(key: str) -> bool: def _is_sensitive_key(key: str) -> bool:
lowered = key.strip().lower() lowered = key.strip().lower()
return any(marker in lowered for marker in _SENSITIVE_KEYWORDS) return any(marker in lowered for marker in _SENSITIVE_KEYWORDS)
@@ -55,10 +66,7 @@ def _is_sensitive_key(key: str) -> bool:
def _redact_scalar(value: Any) -> Any: def _redact_scalar(value: Any) -> Any:
if value is None or isinstance(value, (int, float, bool)): if value is None or isinstance(value, (int, float, bool)):
return value return value
text = str(value) return "[REDACTED]"
if len(text) <= 4:
return "***"
return f"{text[:2]}***{text[-2:]}"
def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any: def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any:
@@ -161,6 +169,10 @@ def configure_logging(
backupCount=max(1, int(log_file_backup_count or 10)), backupCount=max(1, int(log_file_backup_count or 10)),
encoding="utf-8", encoding="utf-8",
) )
try:
os.chmod(log_path, 0o600)
except OSError:
pass
handlers.append(file_handler) handlers.append(file_handler)
context_filter = RequestContextFilter() context_filter = RequestContextFilter()
+41 -20
View File
@@ -7,6 +7,7 @@ from typing import Awaitable, Callable
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from .config import settings from .config import settings
from .db import has_admin_user, init_db from .db import has_admin_user, init_db
@@ -47,11 +48,11 @@ from .logging_config import (
configure_logging, configure_logging,
reset_request_id, reset_request_id,
sanitize_headers, sanitize_headers,
sanitize_value, sanitize_path,
summarize_http_body,
) )
from .runtime import get_runtime_settings from .runtime import get_runtime_settings
from .metrics import record_api, start_metrics from .metrics import record_api, start_metrics
from .secret_storage import validate_secret_storage_configuration
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_background_tasks: list[asyncio.Task[None]] = [] _background_tasks: list[asyncio.Task[None]] = []
@@ -82,22 +83,33 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
operation_token = begin_operation( operation_token = begin_operation(
operation_id, operation_id,
label=request.headers.get("X-Magent-Operation-Label"), label=request.headers.get("X-Magent-Operation-Label"),
path=request.url.path, path=sanitize_path(request.url.path),
) )
request.state.request_id = request_id request.state.request_id = request_id
if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
origin = str(request.headers.get("origin") or "").rstrip("/")
allowed_origin = str(settings.cors_allow_origin or "").rstrip("/")
if origin and origin != allowed_origin:
record_api(request, 403, 0.0)
if operation_id and operation_token is not None:
finish_operation(operation_id, success=False, status_code=403)
reset_operation(operation_token)
reset_request_id(token)
return JSONResponse(
status_code=403,
content={"detail": "Cross-origin state change rejected"},
headers={"X-Request-ID": request_id},
)
started_at = time.perf_counter() started_at = time.perf_counter()
body = await request.body() body_summary = {
body_summary = summarize_http_body(body, request.headers.get("content-type")) "content_type": (request.headers.get("content-type") or "").split(";", 1)[0],
"declared_bytes": request.headers.get("content-length"),
async def receive() -> dict: }
return {"type": "http.request", "body": body, "more_body": False}
request._receive = receive
logger.info( logger.info(
"request started method=%s path=%s query=%s client=%s headers=%s body=%s", "request started method=%s path=%s query_keys=%s client=%s headers=%s body=%s",
request.method, request.method,
request.url.path, sanitize_path(request.url.path),
sanitize_value(dict(request.query_params)), sorted(set(request.query_params.keys())),
request.client.host if request.client else "-", request.client.host if request.client else "-",
sanitize_headers( sanitize_headers(
{ {
@@ -124,7 +136,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
logger.exception( logger.exception(
"request failed method=%s path=%s duration_ms=%s", "request failed method=%s path=%s duration_ms=%s",
request.method, request.method,
request.url.path, sanitize_path(request.url.path),
duration_ms, duration_ms,
) )
if operation_id and operation_token is not None: if operation_id and operation_token is not None:
@@ -140,6 +152,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "no-referrer") response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()") response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
# Keep API responses non-executable and non-embeddable by default. # Keep API responses non-executable and non-embeddable by default.
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"): if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
response.headers.setdefault( response.headers.setdefault(
@@ -149,7 +162,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
logger.info( logger.info(
"request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s", "request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
request.method, request.method,
request.url.path, sanitize_path(request.url.path),
response.status_code, response.status_code,
duration_ms, duration_ms,
sanitize_headers( sanitize_headers(
@@ -203,9 +216,9 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable
def _log_security_configuration_warnings() -> None: def _log_security_configuration_warnings() -> None:
jwt_secret = str(settings.jwt_secret or "").strip() jwt_secret = str(settings.jwt_secret or "").strip()
if not jwt_secret or jwt_secret == "change-me": if len(jwt_secret) < 32 or jwt_secret == "change-me":
logger.warning( logger.warning(
"security configuration warning: JWT_SECRET is unset or still set to the default value" "security configuration warning: JWT_SECRET is missing, short, or still set to the default value"
) )
admin_password = str(settings.admin_password or "") admin_password = str(settings.admin_password or "")
if not admin_password or admin_password == "adminadmin": if not admin_password or admin_password == "adminadmin":
@@ -218,10 +231,17 @@ def _log_security_configuration_warnings() -> None:
) )
def _enforce_secure_startup_configuration() -> None: def _enforce_secret_configuration() -> None:
jwt_secret = str(settings.jwt_secret or "").strip() jwt_secret = str(settings.jwt_secret or "").strip()
if not jwt_secret or jwt_secret == "change-me": if len(jwt_secret) < 32 or jwt_secret == "change-me":
raise RuntimeError("JWT_SECRET must be set to a strong, non-default value before startup.") raise RuntimeError(
"JWT_SECRET must be a strong, non-default value of at least 32 characters before startup."
)
validate_secret_storage_configuration()
def _enforce_secure_startup_configuration() -> None:
_enforce_secret_configuration()
admin_password = str(settings.admin_password or "") admin_password = str(settings.admin_password or "")
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"): if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
raise RuntimeError( raise RuntimeError(
@@ -242,6 +262,7 @@ async def startup() -> None:
) )
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number) logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
_log_security_configuration_warnings() _log_security_configuration_warnings()
_enforce_secret_configuration()
init_db() init_db()
_enforce_secure_startup_configuration() _enforce_secure_startup_configuration()
runtime = get_runtime_settings() runtime = get_runtime_settings()
+43 -15
View File
@@ -39,8 +39,7 @@ from ..db import (
set_user_jellyseerr_id, set_user_jellyseerr_id,
set_setting, set_setting,
set_user_blocked, set_user_blocked,
delete_user_by_username, delete_user_data_by_username,
delete_user_activity_by_username,
set_user_auto_search_enabled, set_user_auto_search_enabled,
set_auto_search_enabled_for_non_admin_users, set_auto_search_enabled_for_non_admin_users,
set_user_email, set_user_email,
@@ -49,6 +48,7 @@ from ..db import (
set_user_profile_id, set_user_profile_id,
set_user_expires_at, set_user_expires_at,
set_user_password, set_user_password,
increment_user_auth_version,
sync_jellyfin_password_state, sync_jellyfin_password_state,
set_user_role, set_user_role,
run_integrity_check, run_integrity_check,
@@ -69,6 +69,7 @@ from ..db import (
get_signup_invite_by_id, get_signup_invite_by_id,
create_signup_invite, create_signup_invite,
update_signup_invite, update_signup_invite,
rotate_signup_invite_code,
delete_signup_invite, delete_signup_invite,
get_signup_invite_by_code, get_signup_invite_by_code,
disable_signup_invites_by_creator, disable_signup_invites_by_creator,
@@ -779,7 +780,7 @@ async def test_email_settings(request: Request) -> Dict[str, Any]:
result = await send_test_email(recipient_email=recipient_email) result = await send_test_email(recipient_email=recipient_email)
except RuntimeError as exc: except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.info("Admin triggered SMTP test: recipient=%s", result.get("recipient_email")) logger.info("Admin triggered SMTP test")
return {"status": "ok", **result} return {"status": "ok", **result}
@@ -1307,12 +1308,12 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str
result["jellyseerr"] = {"status": "error", "detail": _http_error_detail(exc)} result["jellyseerr"] = {"status": "error", "detail": _http_error_detail(exc)}
if action == "remove": if action == "remove":
deleted = delete_user_by_username(username) deletion = delete_user_data_by_username(username)
activity_deleted = delete_user_activity_by_username(username) deleted = bool(deletion.get("deleted"))
result["local"] = { result["local"] = {
"status": "ok" if deleted else "not_found", "status": "ok" if deleted else "not_found",
"deleted": bool(deleted), "deleted": bool(deleted),
"activity_deleted": activity_deleted, "data_cleanup": deletion,
} }
if any( if any(
@@ -1574,6 +1575,7 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc
sync_jellyfin_password_state(username, new_password_clean) sync_jellyfin_password_state(username, new_password_clean)
increment_user_auth_version(username)
return {"status": "ok", "username": username, "provider": "jellyfin"} return {"status": "ok", "username": username, "provider": "jellyfin"}
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@@ -1917,6 +1919,11 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
role=invite.get('role'), max_uses=1, enabled=bool(invite.get('enabled')), role=invite.get('role'), max_uses=1, enabled=bool(invite.get('enabled')),
expires_at=invite.get('expires_at'), recipient_email=recipient_email, expires_at=invite.get('expires_at'), recipient_email=recipient_email,
) )
if not invite:
raise HTTPException(status_code=404, detail='Invite not found')
invite = rotate_signup_invite_code(int(invite['id']), _generate_invite_code())
if not invite:
raise HTTPException(status_code=409, detail='Invite is unavailable')
try: try:
result = await send_templated_email( result = await send_templated_email(
@@ -1930,9 +1937,8 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.info( logger.info(
"Admin sent invite email template: template=%s recipient=%s invite_id=%s username=%s", "Admin sent invite email template: template=%s invite_id=%s username=%s",
template_key, template_key,
result.get("recipient_email"),
invite.get("id") if invite else None, invite.get("id") if invite else None,
user.get("username") if user else None, user.get("username") if user else None,
) )
@@ -1998,15 +2004,14 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
except Exception as exc: except Exception as exc:
email_error = str(exc) email_error = str(exc)
logger.info( logger.info(
"Admin created invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s", "Admin created invite: invite_id=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s has_recipient=%s send_email=%s",
invite.get("id"), invite.get("id"),
invite.get("code"),
invite.get("label"), invite.get("label"),
invite.get("profile_id"), invite.get("profile_id"),
invite.get("role"), invite.get("role"),
invite.get("max_uses"), invite.get("max_uses"),
invite.get("enabled"), invite.get("enabled"),
invite.get("recipient_email"), bool(invite.get("recipient_email")),
send_email, send_email,
) )
return { return {
@@ -2029,7 +2034,11 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
existing = get_signup_invite_by_id(invite_id) existing = get_signup_invite_by_id(invite_id)
if not existing: if not existing:
raise HTTPException(status_code=404, detail="Invite not found") raise HTTPException(status_code=404, detail="Invite not found")
code = _normalize_invite_code(_normalize_optional_text(payload.get("code")) or existing["code"]) requested_code = _normalize_optional_text(payload.get("code"))
if requested_code and not requested_code.startswith("••••") and requested_code != "Protected invite":
code = _normalize_invite_code(requested_code)
else:
code = str(existing.get("code") or "")
profile_id = _parse_optional_profile_id(payload.get("profile_id")) profile_id = _parse_optional_profile_id(payload.get("profile_id"))
enabled = payload.get("enabled") enabled = payload.get("enabled")
if not isinstance(enabled, bool): if not isinstance(enabled, bool):
@@ -2063,6 +2072,10 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
email_error = None email_error = None
if send_email: if send_email:
try: try:
rotated = rotate_signup_invite_code(invite_id, _generate_invite_code())
if not rotated:
raise ValueError("Invite is unavailable")
invite = rotated
email_result = await send_templated_email( email_result = await send_templated_email(
"invited", "invited",
invite=invite, invite=invite,
@@ -2072,15 +2085,14 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
except Exception as exc: except Exception as exc:
email_error = str(exc) email_error = str(exc)
logger.info( logger.info(
"Admin updated invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s", "Admin updated invite: invite_id=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s has_recipient=%s send_email=%s",
invite.get("id"), invite.get("id"),
invite.get("code"),
invite.get("label"), invite.get("label"),
invite.get("profile_id"), invite.get("profile_id"),
invite.get("role"), invite.get("role"),
invite.get("max_uses"), invite.get("max_uses"),
invite.get("enabled"), invite.get("enabled"),
invite.get("recipient_email"), bool(invite.get("recipient_email")),
send_email, send_email,
) )
return { return {
@@ -2096,6 +2108,22 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
} }
@router.post("/invites/{invite_id}/rotate")
async def rotate_invite(
invite_id: int,
current_user: Dict[str, Any] = Depends(require_admin),
) -> Dict[str, Any]:
invite = rotate_signup_invite_code(invite_id, _generate_invite_code())
if not invite:
raise HTTPException(status_code=409, detail="Invite is unavailable")
logger.info(
"Admin rotated invite: invite_id=%s actor=%s",
invite_id,
current_user.get("username"),
)
return {"status": "ok", "invite": invite}
@router.delete("/invites/{invite_id}") @router.delete("/invites/{invite_id}")
async def remove_invite(invite_id: int) -> Dict[str, Any]: async def remove_invite(invite_id: int) -> Dict[str, Any]:
deleted = delete_signup_invite(invite_id) deleted = delete_signup_invite(invite_id)
+83 -108
View File
@@ -1,11 +1,8 @@
from ..feature_guards import require_invites from ..feature_guards import require_invites
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from collections import defaultdict, deque
import logging import logging
import secrets import secrets
import string import string
import time
from threading import Lock
import httpx import httpx
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
@@ -28,6 +25,7 @@ from ..db import (
list_signup_invites, list_signup_invites,
create_signup_invite, create_signup_invite,
update_signup_invite, update_signup_invite,
rotate_signup_invite_code,
delete_signup_invite, delete_signup_invite,
reserve_signup_invite_use, reserve_signup_invite_use,
release_signup_invite_use, release_signup_invite_use,
@@ -39,6 +37,10 @@ from ..db import (
get_global_request_total, get_global_request_total,
get_setting, get_setting,
sync_jellyfin_password_state, sync_jellyfin_password_state,
increment_user_auth_version,
get_rate_limit_status,
record_rate_limit_event,
clear_rate_limit_events,
) )
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient from ..clients.jellyfin import JellyfinClient
@@ -87,14 +89,6 @@ PASSWORD_RESET_GENERIC_MESSAGE = (
"If an account exists for that username or email, a password reset link has been sent." "If an account exists for that username or email, a password reset link has been sent."
) )
_LOGIN_RATE_LOCK = Lock()
_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque)
_RESET_RATE_LOCK = Lock()
_RESET_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_RESET_ATTEMPTS_BY_IDENTIFIER: dict[str, deque[float]] = defaultdict(deque)
def _require_recipient_email(value: object) -> str: def _require_recipient_email(value: object) -> str:
normalized = normalize_delivery_email(value) normalized = normalize_delivery_email(value)
if normalized: if normalized:
@@ -145,12 +139,6 @@ def _password_reset_rate_key_identifier(identifier: str) -> str:
return (identifier or "").strip().lower()[:256] or "<empty>" return (identifier or "").strip().lower()[:256] or "<empty>"
def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None:
cutoff = now - window_seconds
while bucket and bucket[0] < cutoff:
bucket.popleft()
def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) -> dict | None: def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) -> dict | None:
if not users: if not users:
return None return None
@@ -172,56 +160,33 @@ def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) ->
def _record_login_failure(request: Request, username: str) -> None: def _record_login_failure(request: Request, username: str) -> None:
now = time.monotonic()
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
ip_key = _auth_client_ip(request) ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username) user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK: record_rate_limit_event("login-ip", ip_key)
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key] record_rate_limit_event("login-user", user_key)
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key] logger.warning("login failure recorded")
_prune_attempts(ip_bucket, now, window)
_prune_attempts(user_bucket, now, window)
ip_bucket.append(now)
user_bucket.append(now)
logger.warning("login failure recorded username=%s client=%s", user_key, ip_key)
def _clear_login_failures(request: Request, username: str) -> None: def _clear_login_failures(request: Request, username: str) -> None:
ip_key = _auth_client_ip(request) ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username) user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK: clear_rate_limit_events("login-ip", ip_key)
_LOGIN_ATTEMPTS_BY_IP.pop(ip_key, None) clear_rate_limit_events("login-user", user_key)
_LOGIN_ATTEMPTS_BY_USER.pop(user_key, None)
def _enforce_login_rate_limit(request: Request, username: str) -> None: def _enforce_login_rate_limit(request: Request, username: str) -> None:
now = time.monotonic()
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1) window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
max_ip = max(int(settings.auth_rate_limit_max_attempts_ip or 20), 1) max_ip = max(int(settings.auth_rate_limit_max_attempts_ip or 20), 1)
max_user = max(int(settings.auth_rate_limit_max_attempts_user or 10), 1) max_user = max(int(settings.auth_rate_limit_max_attempts_user or 10), 1)
ip_key = _auth_client_ip(request) ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username) user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK: ip_exceeded, ip_retry = get_rate_limit_status("login-ip", ip_key, window, max_ip)
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key] user_exceeded, user_retry = get_rate_limit_status("login-user", user_key, window, max_user)
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key] exceeded = ip_exceeded or user_exceeded
_prune_attempts(ip_bucket, now, window) retry_after = max(ip_retry if ip_exceeded else 1, user_retry if user_exceeded else 1)
_prune_attempts(user_bucket, now, window)
exceeded = len(ip_bucket) >= max_ip or len(user_bucket) >= max_user
retry_after = 1
if exceeded:
retry_candidates = []
if ip_bucket:
retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
if user_bucket:
retry_candidates.append(max(1, int(window - (now - user_bucket[0]))))
if retry_candidates:
retry_after = max(retry_candidates)
if exceeded: if exceeded:
logger.warning( logger.warning(
"login rate limit exceeded username=%s client=%s retry_after=%s", "login rate limit exceeded retry_after=%s", retry_after,
user_key,
ip_key,
retry_after,
) )
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
@@ -231,48 +196,28 @@ def _enforce_login_rate_limit(request: Request, username: str) -> None:
def _record_password_reset_attempt(request: Request, identifier: str) -> None: def _record_password_reset_attempt(request: Request, identifier: str) -> None:
now = time.monotonic()
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
ip_key = _auth_client_ip(request) ip_key = _auth_client_ip(request)
identifier_key = _password_reset_rate_key_identifier(identifier) identifier_key = _password_reset_rate_key_identifier(identifier)
with _RESET_RATE_LOCK: record_rate_limit_event("reset-ip", ip_key)
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key] record_rate_limit_event("reset-identifier", identifier_key)
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key] logger.info("password reset rate event recorded")
_prune_attempts(ip_bucket, now, window)
_prune_attempts(identifier_bucket, now, window)
ip_bucket.append(now)
identifier_bucket.append(now)
logger.info("password reset rate event recorded identifier=%s client=%s", identifier_key, ip_key)
def _enforce_password_reset_rate_limit(request: Request, identifier: str) -> None: def _enforce_password_reset_rate_limit(request: Request, identifier: str) -> None:
now = time.monotonic()
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1) window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
max_ip = max(int(settings.password_reset_rate_limit_max_attempts_ip or 6), 1) max_ip = max(int(settings.password_reset_rate_limit_max_attempts_ip or 6), 1)
max_identifier = max(int(settings.password_reset_rate_limit_max_attempts_identifier or 3), 1) max_identifier = max(int(settings.password_reset_rate_limit_max_attempts_identifier or 3), 1)
ip_key = _auth_client_ip(request) ip_key = _auth_client_ip(request)
identifier_key = _password_reset_rate_key_identifier(identifier) identifier_key = _password_reset_rate_key_identifier(identifier)
with _RESET_RATE_LOCK: ip_exceeded, ip_retry = get_rate_limit_status("reset-ip", ip_key, window, max_ip)
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key] identifier_exceeded, identifier_retry = get_rate_limit_status(
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key] "reset-identifier", identifier_key, window, max_identifier
_prune_attempts(ip_bucket, now, window) )
_prune_attempts(identifier_bucket, now, window) exceeded = ip_exceeded or identifier_exceeded
exceeded = len(ip_bucket) >= max_ip or len(identifier_bucket) >= max_identifier retry_after = max(ip_retry if ip_exceeded else 1, identifier_retry if identifier_exceeded else 1)
retry_after = 1
if exceeded:
retry_candidates = []
if ip_bucket:
retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
if identifier_bucket:
retry_candidates.append(max(1, int(window - (now - identifier_bucket[0]))))
if retry_candidates:
retry_after = max(retry_candidates)
if exceeded: if exceeded:
logger.warning( logger.warning(
"password reset rate limit exceeded identifier=%s client=%s retry_after=%s", "password reset rate limit exceeded retry_after=%s", retry_after,
identifier_key,
ip_key,
retry_after,
) )
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
@@ -400,6 +345,7 @@ def _auth_success_response(response: Response, token: str, user_payload: dict) -
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict: def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
return { return {
"code": invite.get("code"), "code": invite.get("code"),
"code_available": bool(invite.get("code_available")),
"email_bound": bool(invite.get("recipient_email")), "email_bound": bool(invite.get("recipient_email")),
"label": invite.get("label"), "label": invite.get("label"),
"description": invite.get("description"), "description": invite.get("description"),
@@ -493,6 +439,7 @@ def _serialize_self_invite(invite: dict) -> dict:
return { return {
"id": invite.get("id"), "id": invite.get("id"),
"code": invite.get("code"), "code": invite.get("code"),
"code_available": bool(invite.get("code_available")),
"label": invite.get("label"), "label": invite.get("label"),
"description": invite.get("description"), "description": invite.get("description"),
"profile_id": invite.get("profile_id"), "profile_id": invite.get("profile_id"),
@@ -576,6 +523,7 @@ def _serialize_self_service_master_invite(invite: dict | None) -> dict | None:
return { return {
"id": invite.get("id"), "id": invite.get("id"),
"code": invite.get("code"), "code": invite.get("code"),
"code_available": bool(invite.get("code_available")),
"label": invite.get("label"), "label": invite.get("label"),
"description": invite.get("description"), "description": invite.get("description"),
"profile_id": invite.get("profile_id"), "profile_id": invite.get("profile_id"),
@@ -664,7 +612,9 @@ async def login(
detail="This account uses external sign-in. Use the external sign-in option.", detail="This account uses external sign-in. Use the external sign-in option.",
) )
_assert_user_can_login(user) _assert_user_can_login(user)
token = create_access_token(user["username"], user["role"]) token = create_access_token(
user["username"], user["role"], auth_version=int(user.get("auth_version") or 1)
)
_clear_login_failures(request, form_data.username) _clear_login_failures(request, form_data.username)
set_last_login(user["username"]) set_last_login(user["username"])
logger.info( logger.info(
@@ -708,7 +658,9 @@ async def jellyfin_login(
matched_email = extract_jellyseerr_user_email(matched_seerr_user) matched_email = extract_jellyseerr_user_email(matched_seerr_user)
_assert_user_can_login(user) _assert_user_can_login(user)
if user and _has_valid_jellyfin_cache(user, password): if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(canonical_username, "user") token = create_access_token(
canonical_username, "user", auth_version=int(user.get("auth_version") or 1)
)
_clear_login_failures(request, username) _clear_login_failures(request, username)
set_last_login(canonical_username) set_last_login(canonical_username)
logger.info( logger.info(
@@ -775,7 +727,10 @@ async def jellyfin_login(
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map) matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
if matched_id is not None: if matched_id is not None:
set_user_jellyseerr_id(canonical_username, matched_id) set_user_jellyseerr_id(canonical_username, matched_id)
token = create_access_token(canonical_username, "user") refreshed_user = get_user_by_username(canonical_username) or user or {}
token = create_access_token(
canonical_username, "user", auth_version=int(refreshed_user.get("auth_version") or 1)
)
_clear_login_failures(request, username) _clear_login_failures(request, username)
set_last_login(canonical_username) set_last_login(canonical_username)
logger.info( logger.info(
@@ -851,7 +806,10 @@ async def jellyseerr_login(
set_user_jellyseerr_id(canonical_username, jellyseerr_user_id) set_user_jellyseerr_id(canonical_username, jellyseerr_user_id)
if jellyseerr_email: if jellyseerr_email:
set_user_email(canonical_username, jellyseerr_email) set_user_email(canonical_username, jellyseerr_email)
token = create_access_token(canonical_username, "user") refreshed_user = get_user_by_username(canonical_username) or user or {}
token = create_access_token(
canonical_username, "user", auth_version=int(refreshed_user.get("auth_version") or 1)
)
_clear_login_failures(request, form_data.username) _clear_login_failures(request, form_data.username)
set_last_login(canonical_username) set_last_login(canonical_username)
logger.info( logger.info(
@@ -873,7 +831,10 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
@router.post("/logout") @router.post("/logout")
async def logout(response: Response) -> dict: async def logout(
response: Response, current_user: dict = Depends(get_current_user)
) -> dict:
increment_user_auth_version(str(current_user.get("username") or ""))
clear_auth_cookies(response) clear_auth_cookies(response)
return {"status": "ok"} return {"status": "ok"}
@@ -884,6 +845,7 @@ async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
current_user["username"], current_user["username"],
current_user["role"], current_user["role"],
expires_seconds=STREAM_TOKEN_TTL_SECONDS, expires_seconds=STREAM_TOKEN_TTL_SECONDS,
auth_version=int(current_user.get("auth_version") or 1),
) )
return { return {
"stream_token": token, "stream_token": token,
@@ -923,11 +885,7 @@ async def signup(payload: dict, response: Response) -> dict:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if get_user_by_username(username): if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
logger.info( logger.info("signup attempt username=%s", username)
"signup attempt username=%s invite_code=%s",
username,
invite_code,
)
invite = get_signup_invite_by_code(invite_code) invite = get_signup_invite_by_code(invite_code)
if not invite: if not invite:
@@ -1039,7 +997,7 @@ async def signup(payload: dict, response: Response) -> dict:
auto_search_enabled=auto_search_enabled, auto_search_enabled=auto_search_enabled,
profile_id=int(profile_id) if profile_id is not None else None, profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at, expires_at=expires_at,
invited_by_code=invite.get("code"), invited_by_code=f"invite:{invite.get('id')}",
) )
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -1066,15 +1024,18 @@ async def signup(payload: dict, response: Response) -> dict:
# Welcome email delivery is best-effort and must not break signup. # Welcome email delivery is best-effort and must not break signup.
logger.warning("Welcome email send skipped for %s: %s", username, exc) logger.warning("Welcome email send skipped for %s: %s", username, exc)
_assert_user_can_login(created_user) _assert_user_can_login(created_user)
token = create_access_token(username, role) refreshed_user = get_user_by_username(username) or created_user or {}
token = create_access_token(
username, role, auth_version=int(refreshed_user.get("auth_version") or 1)
)
set_last_login(username) set_last_login(username)
logger.info( logger.info(
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s", "signup success username=%s role=%s auth_provider=%s profile_id=%s invite_id=%s",
username, username,
role, role,
created_user.get("auth_provider") if created_user else auth_provider, created_user.get("auth_provider") if created_user else auth_provider,
created_user.get("profile_id") if created_user else None, created_user.get("profile_id") if created_user else None,
invite.get("code"), invite.get("id"),
) )
return _auth_success_response( return _auth_success_response(
response, response,
@@ -1110,8 +1071,7 @@ async def forgot_password(payload: dict, request: Request) -> dict:
) )
client_ip = _auth_client_ip(request) client_ip = _auth_client_ip(request)
safe_identifier = identifier.strip().lower()[:256] logger.info("password reset requested")
logger.info("password reset requested identifier=%s client=%s", safe_identifier, client_ip)
try: try:
reset_result = await request_password_reset( reset_result = await request_password_reset(
identifier, identifier,
@@ -1120,24 +1080,17 @@ async def forgot_password(payload: dict, request: Request) -> dict:
) )
if reset_result.get("issued"): if reset_result.get("issued"):
logger.info( logger.info(
"password reset issued username=%s provider=%s recipient=%s client=%s", "password reset issued username=%s provider=%s",
reset_result.get("username"), reset_result.get("username"),
reset_result.get("auth_provider"), reset_result.get("auth_provider"),
reset_result.get("recipient_email"),
client_ip,
) )
else: else:
logger.info( logger.info(
"password reset request completed with no eligible account identifier=%s client=%s", "password reset request completed with no eligible account",
safe_identifier,
client_ip,
) )
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
"password reset email dispatch failed identifier=%s client=%s detail=%s", "password reset email dispatch failed detail=%s", type(exc).__name__,
safe_identifier,
client_ip,
str(exc),
) )
return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE} return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE}
@@ -1371,8 +1324,13 @@ async def update_profile_invite(
_require_self_service_invite_access(current_user) _require_self_service_invite_access(current_user)
existing = _get_owned_invite(invite_id, current_user) existing = _get_owned_invite(invite_id, current_user)
requested_code = payload.get("code", existing.get("code")) requested_code = payload.get("code")
if isinstance(requested_code, str) and requested_code.strip(): if (
isinstance(requested_code, str)
and requested_code.strip()
and not requested_code.strip().startswith("••••")
and requested_code.strip() != "Protected invite"
):
code = _normalize_invite_code(requested_code) code = _normalize_invite_code(requested_code)
else: else:
code = str(existing.get("code") or "").strip() code = str(existing.get("code") or "").strip()
@@ -1427,6 +1385,10 @@ async def update_profile_invite(
email_error = None email_error = None
if send_email: if send_email:
try: try:
rotated = rotate_signup_invite_code(invite_id, _generate_invite_code())
if not rotated:
raise ValueError("Invite is unavailable")
invite = rotated
email_result = await send_templated_email( email_result = await send_templated_email(
"invited", "invited",
invite=invite, invite=invite,
@@ -1450,6 +1412,18 @@ async def update_profile_invite(
} }
@router.post("/profile/invites/{invite_id}/rotate")
async def rotate_profile_invite(
invite_id: int, current_user: dict = Depends(get_current_user)
) -> dict:
_require_self_service_invite_access(current_user)
_get_owned_invite(invite_id, current_user)
invite = rotate_signup_invite_code(invite_id, _generate_invite_code())
if not invite:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite is unavailable")
return {"status": "ok", "invite": _serialize_self_invite(invite)}
@router.delete("/profile/invites/{invite_id}") @router.delete("/profile/invites/{invite_id}")
async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict: async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict:
_require_self_service_invite_access(current_user) _require_self_service_invite_access(current_user)
@@ -1531,6 +1505,7 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
# Keep Magent's password hash and Jellyfin auth cache aligned with Jellyfin. # Keep Magent's password hash and Jellyfin auth cache aligned with Jellyfin.
sync_jellyfin_password_state(username, new_password_clean) sync_jellyfin_password_state(username, new_password_clean)
increment_user_auth_version(username)
logger.info("password change completed username=%s provider=jellyfin", username) logger.info("password change completed username=%s provider=jellyfin", username)
return {"status": "ok", "provider": "jellyfin"} return {"status": "ok", "provider": "jellyfin"}
+22 -4
View File
@@ -1,4 +1,5 @@
import os import os
import warnings
from io import BytesIO from io import BytesIO
from typing import Any, Dict from typing import Any, Dict
@@ -15,6 +16,10 @@ _BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "as
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png") _BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico") _BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower() _BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
_MAX_UPLOAD_BYTES = 5 * 1024 * 1024
_MAX_IMAGE_PIXELS = 25_000_000
_ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/webp"}
_ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
def _ensure_branding_dir() -> None: def _ensure_branding_dir() -> None:
@@ -110,14 +115,27 @@ async def branding_favicon() -> FileResponse:
async def save_branding_image(file: UploadFile) -> Dict[str, Any]: async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
if not file.content_type or not file.content_type.startswith("image/"): content_type = str(file.content_type or "").lower()
raise HTTPException(status_code=400, detail="Please upload an image file.") extension = os.path.splitext(str(file.filename or ""))[1].lower()
content = await file.read() if content_type not in _ALLOWED_IMAGE_TYPES or extension not in _ALLOWED_IMAGE_EXTENSIONS:
raise HTTPException(status_code=400, detail="Upload a PNG, JPEG, or WebP image.")
content = await file.read(_MAX_UPLOAD_BYTES + 1)
if not content: if not content:
raise HTTPException(status_code=400, detail="Uploaded file is empty.") raise HTTPException(status_code=400, detail="Uploaded file is empty.")
if len(content) > _MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Image is too large (maximum 5 MB).")
try: try:
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
candidate = Image.open(BytesIO(content))
if candidate.format not in {"PNG", "JPEG", "WEBP"}:
raise ValueError("Unsupported image format")
if candidate.width * candidate.height > _MAX_IMAGE_PIXELS:
raise Image.DecompressionBombError("Image pixel limit exceeded")
candidate.verify()
image = Image.open(BytesIO(content)) image = Image.open(BytesIO(content))
except OSError as exc: image.load()
except (OSError, ValueError, Image.DecompressionBombError, Image.DecompressionBombWarning) as exc:
raise HTTPException(status_code=400, detail="Image file could not be read.") from exc raise HTTPException(status_code=400, detail="Image file could not be read.") from exc
_ensure_branding_dir() _ensure_branding_dir()
+68 -32
View File
@@ -1593,11 +1593,54 @@ def get_requests_sync_state() -> Dict[str, Any]:
async def _ensure_request_access( async def _ensure_request_access(
client: JellyseerrClient, request_id: int, user: Dict[str, str] client: JellyseerrClient,
) -> None: request_id: int,
if user.get("role") == "admin" or user.get("username"): user: Dict[str, Any],
return *,
require_owner: bool = False,
) -> Optional[Dict[str, Any]]:
if user.get("role") == "admin":
return None
if not user.get("username"):
raise HTTPException(status_code=403, detail="Request not accessible for this user") raise HTTPException(status_code=403, detail="Request not accessible for this user")
if not require_owner:
return None
request_data = await client.get_request(str(request_id))
if not isinstance(request_data, dict):
raise HTTPException(status_code=404, detail="Request not found")
requester_id = _extract_requested_by_id(request_data)
current_seerr_id = user.get("jellyseerr_user_id")
if isinstance(current_seerr_id, int) and requester_id == current_seerr_id:
return request_data
if _request_matches_user(request_data, str(user.get("username") or "")):
return request_data
email = str(user.get("email") or "").strip()
if email and _request_matches_user(request_data, email):
return request_data
raise HTTPException(
status_code=403,
detail="Only the original requester or an administrator can change this request",
)
async def _ensure_request_mutation_access(
runtime: Any, request_id: int, user: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Fail closed when a non-admin request owner cannot be verified."""
if user.get("role") == "admin":
return None
client = JellyseerrClient(
getattr(runtime, "jellyseerr_base_url", None),
getattr(runtime, "jellyseerr_api_key", None),
)
if not client.configured():
raise HTTPException(
status_code=403,
detail="Request ownership cannot be verified while Seerr is unavailable",
)
return await _ensure_request_access(
client, request_id, user, require_owner=True
)
def _build_recent_map(response: Dict[str, Any]) -> Dict[int, Dict[str, Any]]: def _build_recent_map(response: Dict[str, Any]) -> Dict[int, Dict[str, Any]]:
@@ -1948,9 +1991,6 @@ async def issue_target_options(
if not request_id.isdigit(): if not request_id.isdigit():
raise HTTPException(status_code=400, detail="Invalid request id") raise HTTPException(status_code=400, detail="Invalid request id")
runtime = get_runtime_settings() runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item") arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict): if not isinstance(arr_item, dict):
@@ -2136,9 +2176,7 @@ async def action_replace_media(
) )
runtime = get_runtime_settings() runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) await _ensure_request_mutation_access(runtime, int(request_id), user)
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item") arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict): if not isinstance(arr_item, dict):
@@ -2360,6 +2398,7 @@ async def action_search_missing_media(
payload.get("season_numbers"), field="season_numbers", maximum=100, minimum=0 payload.get("season_numbers"), field="season_numbers", maximum=100, minimum=0
) )
runtime = get_runtime_settings() runtime = get_runtime_settings()
await _ensure_request_mutation_access(runtime, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item") arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int): if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
@@ -2497,9 +2536,7 @@ async def action_add_seasons(
raise HTTPException(status_code=400, detail="Choose at least one season") raise HTTPException(status_code=400, detail="Choose at least one season")
runtime = get_runtime_settings() runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) await _ensure_request_mutation_access(runtime, int(request_id), user)
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
if snapshot.request_type != RequestType.tv: if snapshot.request_type != RequestType.tv:
raise HTTPException(status_code=400, detail="Additional seasons are only available for TV requests") raise HTTPException(status_code=400, detail="Additional seasons are only available for TV requests")
@@ -2627,6 +2664,7 @@ async def action_repair_subtitles(
episode_ids = _positive_id_list(payload.get("episode_ids"), field="episode_ids", maximum=100) episode_ids = _positive_id_list(payload.get("episode_ids"), field="episode_ids", maximum=100)
forced = payload.get("forced") is True forced = payload.get("forced") is True
runtime = get_runtime_settings() runtime = get_runtime_settings()
await _ensure_request_mutation_access(runtime, int(request_id), user)
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key) bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
if not bazarr.configured() or not runtime.bazarr_api_key: if not bazarr.configured() or not runtime.bazarr_api_key:
raise HTTPException(status_code=400, detail="Bazarr is not configured") raise HTTPException(status_code=400, detail="Bazarr is not configured")
@@ -2772,8 +2810,11 @@ async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_cur
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not seerr.configured(): if not seerr.configured():
raise HTTPException(status_code=400, detail="Seerr is not configured") raise HTTPException(status_code=400, detail="Seerr is not configured")
await _ensure_request_access(seerr, int(request_id), user) fresh_request = await _ensure_request_access(
seerr, int(request_id), user, require_owner=True
)
if fresh_request is None:
try: try:
fresh_request = await seerr.get_request(request_id) fresh_request = await seerr.get_request(request_id)
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
@@ -3444,10 +3485,13 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_
return triage_snapshot(snapshot) return triage_snapshot(snapshot)
async def _request_language_context(request_id, user): async def _request_language_context(request_id, user, *, require_owner: bool = False):
runtime = get_runtime_settings() runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
await _ensure_request_access(seerr, int(request_id), user) request = await _ensure_request_access(
seerr, int(request_id), user, require_owner=require_owner
)
if request is None:
request = await seerr.get_request(request_id) request = await seerr.get_request(request_id)
if not isinstance(request, dict) or request.get('type') != 'movie': if not isinstance(request, dict) or request.get('type') != 'movie':
return runtime, None, None return runtime, None, None
@@ -3479,7 +3523,9 @@ async def accept_request_language(request_id: str, payload: dict, user: dict = D
raise HTTPException(403, 'Search and download changes are disabled for this account.') raise HTTPException(403, 'Search and download changes are disabled for this account.')
if payload.get('acceptOriginalLanguage') is not True: if payload.get('acceptOriginalLanguage') is not True:
raise HTTPException(400, 'Explicitly accept original-language audio before continuing.') raise HTTPException(400, 'Explicitly accept original-language audio before continuing.')
runtime, tmdb_id, language = await _request_language_context(request_id, user) runtime, tmdb_id, language = await _request_language_context(
request_id, user, require_owner=True
)
if not language: if not language:
raise HTTPException(409, 'This request has no verified non-English original language.') raise HTTPException(409, 'This request has no verified non-English original language.')
if payload.get('languageCode') != language['code']: if payload.get('languageCode') != language['code']:
@@ -3502,9 +3548,7 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
total_missing = 0 total_missing = 0
next_offset = None next_offset = None
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) await _ensure_request_mutation_access(runtime, int(request_id), user)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item") arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int): if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
@@ -3612,9 +3656,7 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
if not _user_can_use_search_auto(user): if not _user_can_use_search_auto(user):
raise HTTPException(status_code=403, detail="Auto search and download is disabled for this user") raise HTTPException(status_code=403, detail="Auto search and download is disabled for this user")
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) await _ensure_request_mutation_access(runtime, int(request_id), user)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item") arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict): if not isinstance(arr_item, dict):
@@ -3664,9 +3706,7 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
@router.post("/{request_id}/actions/qbit/resume") @router.post("/{request_id}/actions/qbit/resume")
async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) await _ensure_request_mutation_access(runtime, int(request_id), user)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
queue = snapshot.raw.get("arr", {}).get("queue") queue = snapshot.raw.get("arr", {}).get("queue")
download_ids = _download_ids(_queue_records(queue)) download_ids = _download_ids(_queue_records(queue))
@@ -3711,9 +3751,7 @@ async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_curr
@router.post("/{request_id}/actions/readd") @router.post("/{request_id}/actions/readd")
async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) await _ensure_request_mutation_access(runtime, int(request_id), user)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
jelly = snapshot.raw.get("jellyseerr") or {} jelly = snapshot.raw.get("jellyseerr") or {}
media = jelly.get("media") or {} media = jelly.get("media") or {}
@@ -3870,9 +3908,7 @@ async def action_grab(
request_id: str, payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user) request_id: str, payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)
) -> dict: ) -> dict:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) await _ensure_request_mutation_access(runtime, int(request_id), user)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
guid = payload.get("guid") guid = payload.get("guid")
indexer_id = payload.get("indexerId") indexer_id = payload.get("indexerId")
+74
View File
@@ -0,0 +1,74 @@
import base64
import hashlib
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from .config import settings
ENCRYPTED_PREFIX = "enc:v1:"
SENSITIVE_SETTING_KEYS = frozenset(
{
"jellystat_api_key", "magent_ssl_certificate_pem", "magent_ssl_private_key_pem",
"magent_notify_email_smtp_password", "magent_notify_discord_webhook_url",
"magent_notify_telegram_bot_token", "magent_notify_push_token",
"magent_notify_push_user_key", "magent_notify_webhook_url", "jellyseerr_api_key",
"jellyfin_api_key", "sonarr_api_key", "radarr_api_key", "bazarr_api_key",
"prowlarr_api_key", "qbittorrent_password",
}
)
def _fernet_key() -> bytes:
configured = str(settings.settings_encryption_key or "").strip()
if configured:
try:
decoded = base64.urlsafe_b64decode(configured.encode("ascii"))
except Exception as exc:
raise RuntimeError("SETTINGS_ENCRYPTION_KEY must be a valid Fernet key") from exc
if len(decoded) != 32:
raise RuntimeError("SETTINGS_ENCRYPTION_KEY must decode to exactly 32 bytes")
return configured.encode("ascii")
jwt_secret = str(settings.jwt_secret or "").strip()
if len(jwt_secret) < 32 or jwt_secret == "change-me":
raise RuntimeError(
"SETTINGS_ENCRYPTION_KEY is required when JWT_SECRET is not a strong migration key"
)
derived = hashlib.sha256(("magent-settings-v1:" + jwt_secret).encode("utf-8")).digest()
return base64.urlsafe_b64encode(derived)
def is_sensitive_setting(key: str) -> bool:
return str(key or "").strip().lower() in SENSITIVE_SETTING_KEYS
def validate_secret_storage_configuration() -> None:
"""Validate the configured or JWT-derived Fernet key without touching stored data."""
Fernet(_fernet_key())
def encrypt_setting_value(key: str, value: Optional[str]) -> Optional[str]:
if value is None or not is_sensitive_setting(key):
return value
text = str(value)
if text.startswith(ENCRYPTED_PREFIX):
return text
token = Fernet(_fernet_key()).encrypt(text.encode("utf-8")).decode("ascii")
return ENCRYPTED_PREFIX + token
def decrypt_setting_value(key: str, value: Optional[str]) -> Optional[str]:
if value is None or not is_sensitive_setting(key):
return value
text = str(value)
if not text.startswith(ENCRYPTED_PREFIX):
return text
try:
return Fernet(_fernet_key()).decrypt(
text[len(ENCRYPTED_PREFIX) :].encode("ascii")
).decode("utf-8")
except InvalidToken as exc:
raise RuntimeError(
f"Stored secret '{key}' cannot be decrypted with the configured key"
) from exc
+50 -7
View File
@@ -1,4 +1,5 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import uuid
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from passlib.context import CryptContext from passlib.context import CryptContext
@@ -7,9 +8,15 @@ from jwt import InvalidTokenError
from .config import settings from .config import settings
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") _pwd_context = CryptContext(
schemes=["argon2", "pbkdf2_sha256"],
deprecated=["pbkdf2_sha256"],
argon2__memory_cost=65536,
argon2__time_cost=3,
argon2__parallelism=4,
)
_ALGORITHM = "HS256" _ALGORITHM = "HS256"
MIN_PASSWORD_LENGTH = 8 MIN_PASSWORD_LENGTH = 12
PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters." PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
@@ -18,7 +25,17 @@ def hash_password(password: str) -> str:
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
try:
return _pwd_context.verify(plain_password, hashed_password) return _pwd_context.verify(plain_password, hashed_password)
except (TypeError, ValueError):
return False
def verify_and_update_password(plain_password: str, hashed_password: str) -> tuple[bool, Optional[str]]:
try:
return _pwd_context.verify_and_update(plain_password, hashed_password)
except (TypeError, ValueError):
return False, None
def validate_password_policy(password: str) -> str: def validate_password_policy(password: str) -> str:
@@ -34,32 +51,58 @@ def _create_token(
*, *,
expires_at: datetime, expires_at: datetime,
token_type: str = "access", token_type: str = "access",
auth_version: int = 1,
) -> str: ) -> str:
issued_at = datetime.now(timezone.utc)
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"sub": subject, "sub": subject,
"role": role, "role": role,
"typ": token_type, "typ": token_type,
"exp": expires_at, "exp": expires_at,
"iat": issued_at,
"jti": uuid.uuid4().hex,
"iss": settings.jwt_issuer,
"aud": settings.jwt_audience,
"ver": max(1, int(auth_version or 1)),
} }
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM) return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str: def create_access_token(
subject: str,
role: str,
expires_minutes: Optional[int] = None,
*,
auth_version: int = 1,
) -> str:
if not settings.jwt_secret: if not settings.jwt_secret:
raise ValueError("JWT_SECRET is not configured") raise ValueError("JWT_SECRET is not configured")
minutes = expires_minutes or settings.jwt_exp_minutes minutes = expires_minutes or settings.jwt_exp_minutes
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes) expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
return _create_token(subject, role, expires_at=expires, token_type="access") return _create_token(subject, role, expires_at=expires, token_type="access", auth_version=auth_version)
def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str: def create_stream_token(
subject: str,
role: str,
expires_seconds: int = 120,
*,
auth_version: int = 1,
) -> str:
expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120))) expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
return _create_token(subject, role, expires_at=expires, token_type="sse") return _create_token(subject, role, expires_at=expires, token_type="sse", auth_version=auth_version)
def decode_token(token: str) -> Dict[str, Any]: def decode_token(token: str) -> Dict[str, Any]:
if not settings.jwt_secret: if not settings.jwt_secret:
raise ValueError("JWT_SECRET is not configured") raise ValueError("JWT_SECRET is not configured")
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM]) return jwt.decode(
token,
settings.jwt_secret,
algorithms=[_ALGORITHM],
audience=settings.jwt_audience,
issuer=settings.jwt_issuer,
options={"require": ["exp", "iat", "jti", "iss", "aud", "sub", "typ", "ver"]},
)
class TokenError(Exception): class TokenError(Exception):
+8 -21
View File
@@ -1025,16 +1025,12 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
raise RuntimeError("SMTP email settings are incomplete.") raise RuntimeError("SMTP email settings are incomplete.")
local_hostname = _derive_mail_hostname(from_address=from_address) local_hostname = _derive_mail_hostname(from_address=from_address)
logger.info( logger.info(
"smtp send started recipient=%s from=%s host=%s port=%s tls=%s ssl=%s auth=%s subject=%s ehlo=%s", "smtp send started host=%s port=%s tls=%s ssl=%s auth=%s",
recipient_email,
from_address,
host, host,
port, port,
use_tls, use_tls,
use_ssl, use_ssl,
bool(username and password), bool(username and password),
subject,
local_hostname,
) )
if delivery_warning: if delivery_warning:
logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning) logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning)
@@ -1083,11 +1079,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
message=message, message=message,
) )
logger.info( logger.info(
"smtp send accepted recipient=%s host=%s mode=ssl provider_message_id=%s provider_internal_id=%s", "smtp send accepted host=%s mode=ssl", host,
recipient_email,
host,
receipt.get("provider_message_id"),
receipt.get("provider_internal_id"),
) )
return receipt return receipt
@@ -1100,7 +1092,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
logger.debug("smtp starttls negotiated host=%s port=%s", host, port) logger.debug("smtp starttls negotiated host=%s port=%s", host, port)
if username and password: if username and password:
smtp.login(username, password) smtp.login(username, password)
logger.debug("smtp login succeeded host=%s username=%s", host, username) logger.debug("smtp login succeeded host=%s", host)
receipt = _send_via_smtp_session( receipt = _send_via_smtp_session(
smtp, smtp,
from_address=from_address, from_address=from_address,
@@ -1108,11 +1100,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
message=message, message=message,
) )
logger.info( logger.info(
"smtp send accepted recipient=%s host=%s mode=plain provider_message_id=%s provider_internal_id=%s", "smtp send accepted host=%s mode=plain", host,
recipient_email,
host,
receipt.get("provider_message_id"),
receipt.get("provider_internal_id"),
) )
return receipt return receipt
@@ -1153,7 +1141,7 @@ async def send_templated_email(
body_text=rendered["body_text"], body_text=rendered["body_text"],
body_html=rendered["body_html"], body_html=rendered["body_html"],
) )
logger.info("Email template sent: template=%s recipient=%s", template_key, resolved_email) logger.info("Email template sent: template=%s", template_key)
return { return {
"recipient_email": resolved_email, "recipient_email": resolved_email,
"subject": rendered["subject"], "subject": rendered["subject"],
@@ -1185,7 +1173,7 @@ async def send_generic_email(
body_text=body_text.strip(), body_text=body_text.strip(),
body_html=body_html.strip(), body_html=body_html.strip(),
) )
logger.info("Generic email sent recipient=%s subject=%s", resolved_email, subject) logger.info("Generic email sent")
return { return {
"recipient_email": resolved_email, "recipient_email": resolved_email,
"subject": subject.strip() or f"{env_settings.app_name} notification", "subject": subject.strip() or f"{env_settings.app_name} notification",
@@ -1284,7 +1272,7 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st
body_text=body_text, body_text=body_text,
body_html=body_html, body_html=body_html,
) )
logger.info("SMTP test email sent: recipient=%s", resolved_email) logger.info("SMTP test email sent")
result = {"recipient_email": resolved_email, "subject": subject} result = {"recipient_email": resolved_email, "subject": subject}
result.update( result.update(
{ {
@@ -1383,9 +1371,8 @@ async def send_password_reset_email(
body_html=body_html, body_html=body_html,
) )
logger.info( logger.info(
"Password reset email sent: username=%s recipient=%s provider=%s", "Password reset email sent: username=%s provider=%s",
username, username,
resolved_email,
auth_provider, auth_provider,
) )
result = { result = {
+3 -1
View File
@@ -18,6 +18,7 @@ from ..db import (
mark_password_reset_token_used, mark_password_reset_token_used,
set_user_auth_provider, set_user_auth_provider,
set_user_password, set_user_password,
increment_user_auth_version,
sync_jellyfin_password_state, sync_jellyfin_password_state,
) )
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
@@ -243,7 +244,7 @@ async def request_password_reset(
delete_expired_password_reset_tokens() delete_expired_password_reset_tokens()
target = await _resolve_reset_target(identifier) target = await _resolve_reset_target(identifier)
if not target: if not target:
logger.info("password reset requested with no eligible match identifier=%s", identifier.strip().lower()[:256]) logger.info("password reset requested with no eligible match")
return {"status": "ok", "issued": False} return {"status": "ok", "issued": False}
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
@@ -324,6 +325,7 @@ async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
raise ValueError("Password reset link is invalid or has expired.") raise ValueError("Password reset link is invalid or has expired.")
await client.set_user_password(user_id, new_password) await client.set_user_password(user_id, new_password)
sync_jellyfin_password_state(username, new_password) sync_jellyfin_password_state(username, new_password)
increment_user_auth_version(username)
if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin": if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
set_user_auth_provider(username, "jellyfin") set_user_auth_provider(username, "jellyfin")
mark_password_reset_token_used(token) mark_password_reset_token_used(token)
+2
View File
@@ -5,6 +5,8 @@ pydantic==2.12.5
pydantic-settings==2.14.2 pydantic-settings==2.14.2
PyJWT==2.13.0 PyJWT==2.13.0
passlib==1.7.4 passlib==1.7.4
argon2-cffi==25.1.0
cryptography==50.0.1
python-multipart==0.0.31 python-multipart==0.0.31
Pillow==12.3.0 Pillow==12.3.0
prometheus-client==0.22.1 prometheus-client==0.22.1
+236 -20
View File
@@ -6,23 +6,25 @@ from unittest.mock import AsyncMock, call, patch
import httpx import httpx
from fastapi import HTTPException from fastapi import HTTPException
from passlib.context import CryptContext
from starlette.requests import Request from starlette.requests import Request
from backend.app import db from backend.app import db
from backend.app.clients.base import _operation_error_message, _operation_result_message from backend.app.clients.base import _operation_error_message, _operation_result_message
from backend.app.clients.jellyfin import _availability_message from backend.app.clients.jellyfin import _availability_message
from backend.app.clients.qbittorrent import _torrent_result_message from backend.app.clients.qbittorrent import _torrent_result_message
from backend.app.auth import require_admin from backend.app.auth import _load_current_user_from_token, require_admin
from backend.app.config import settings from backend.app.config import settings
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
from backend.app.models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop from backend.app.models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
from backend.app.routers import auth as auth_router from backend.app.routers import auth as auth_router
from backend.app.routers import admin as admin_router from backend.app.routers import admin as admin_router
from backend.app.routers import branding as branding_router
from backend.app.routers import portal as portal_router from backend.app.routers import portal as portal_router
from backend.app.routers import requests as requests_router from backend.app.routers import requests as requests_router
from backend.app.routers import site as site_router from backend.app.routers import site as site_router
from backend.app.routers import status as status_router from backend.app.routers import status as status_router
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy from backend.app.security import PASSWORD_POLICY_MESSAGE, create_access_token, validate_password_policy
from backend.app.services import password_reset from backend.app.services import password_reset
from backend.app.services import issue_resolution from backend.app.services import issue_resolution
from backend.app.services.operation_progress import ( from backend.app.services.operation_progress import (
@@ -71,21 +73,16 @@ class TempDatabaseMixin:
self._tempdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) self._tempdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self._original_sqlite_path = settings.sqlite_path self._original_sqlite_path = settings.sqlite_path
self._original_journal_mode = getattr(settings, "sqlite_journal_mode", "DELETE") self._original_journal_mode = getattr(settings, "sqlite_journal_mode", "DELETE")
self._original_settings_encryption_key = settings.settings_encryption_key
settings.sqlite_path = os.path.join(self._tempdir.name, "test.db") settings.sqlite_path = os.path.join(self._tempdir.name, "test.db")
settings.sqlite_journal_mode = "DELETE" settings.sqlite_journal_mode = "DELETE"
auth_router._LOGIN_ATTEMPTS_BY_IP.clear() settings.settings_encryption_key = "bWFnZW50LXNlY3VyaXR5LXRlc3Qta2V5LTMyLWJ5dGU="
auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
auth_router._RESET_ATTEMPTS_BY_IP.clear()
auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
db.init_db() db.init_db()
def tearDown(self) -> None: def tearDown(self) -> None:
settings.sqlite_path = self._original_sqlite_path settings.sqlite_path = self._original_sqlite_path
settings.sqlite_journal_mode = self._original_journal_mode settings.sqlite_journal_mode = self._original_journal_mode
auth_router._LOGIN_ATTEMPTS_BY_IP.clear() settings.settings_encryption_key = self._original_settings_encryption_key
auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
auth_router._RESET_ATTEMPTS_BY_IP.clear()
auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
self._tempdir.cleanup() self._tempdir.cleanup()
super_method = getattr(super(), "tearDown", None) super_method = getattr(super(), "tearDown", None)
if callable(super_method): if callable(super_method):
@@ -98,7 +95,204 @@ class PasswordPolicyTests(unittest.TestCase):
validate_password_policy("short") validate_password_policy("short")
def test_validate_password_policy_trims_whitespace(self) -> None: def test_validate_password_policy_trims_whitespace(self) -> None:
self.assertEqual(validate_password_policy(" password123 "), "password123") self.assertEqual(validate_password_policy(" password1234 "), "password1234")
class SecurityHardeningTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
super().setUp()
self._jwt_secret = patch.object(
settings, "jwt_secret", "security-hardening-tests-secret-123456789"
)
self._jwt_secret.start()
self.addCleanup(self._jwt_secret.stop)
def test_sensitive_settings_are_encrypted_at_rest(self) -> None:
db.set_setting("jellyfin_api_key", "private-api-key")
with db._connect() as conn:
stored = conn.execute(
"SELECT value FROM settings WHERE key = ?", ("jellyfin_api_key",)
).fetchone()[0]
self.assertTrue(stored.startswith("enc:v1:"))
self.assertNotIn("private-api-key", stored)
self.assertEqual(db.get_setting("jellyfin_api_key"), "private-api-key")
def test_invites_are_hashed_and_rotation_invalidates_old_link(self) -> None:
created = db.create_signup_invite(code="TopSecretInvite42")
invite_id = int(created["id"])
with db._connect() as conn:
stored = conn.execute(
"SELECT code FROM signup_invites WHERE id = ?", (invite_id,)
).fetchone()[0]
self.assertTrue(stored.startswith("sha256:"))
self.assertNotIn("TOPSECRETINVITE42", stored.upper())
self.assertFalse(db.get_signup_invite_by_id(invite_id)["code_available"])
self.assertIsNotNone(db.get_signup_invite_by_code("TopSecretInvite42"))
rotated = db.rotate_signup_invite_code(invite_id, "ReplacementInvite99")
self.assertTrue(rotated["code_available"])
self.assertIsNone(db.get_signup_invite_by_code("TopSecretInvite42"))
self.assertIsNotNone(db.get_signup_invite_by_code("ReplacementInvite99"))
def test_legacy_invites_and_plaintext_settings_migrate_in_place(self) -> None:
created = db.create_signup_invite(code="TemporaryInvite77")
with db._connect() as conn:
conn.execute(
"UPDATE signup_invites SET code = ?, code_hint = NULL WHERE id = ?",
("Legacy-Code-77", int(created["id"])),
)
conn.execute(
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
("radarr_api_key", "legacy-plaintext-key", "2026-09-17T00:00:00+00:00"),
)
db.init_db()
migrated = db.get_signup_invite_by_code("Legacy-Code-77")
self.assertEqual(migrated["id"], created["id"])
self.assertEqual(db.get_setting("radarr_api_key"), "legacy-plaintext-key")
with db._connect() as conn:
invite_code = conn.execute(
"SELECT code FROM signup_invites WHERE id = ?", (int(created["id"]),)
).fetchone()[0]
stored_setting = conn.execute(
"SELECT value FROM settings WHERE key = 'radarr_api_key'"
).fetchone()[0]
self.assertTrue(invite_code.startswith("sha256:"))
self.assertTrue(stored_setting.startswith("enc:v1:"))
def test_legacy_password_hash_is_replaced_with_argon2(self) -> None:
password = "Example-password123!"
db.create_user("legacy", password)
legacy_hash = CryptContext(schemes=["pbkdf2_sha256"]).hash(password)
with db._connect() as conn:
conn.execute(
"UPDATE users SET password_hash = ? WHERE username = ?",
(legacy_hash, "legacy"),
)
self.assertIsNotNone(db.verify_user_password("legacy", password))
self.assertTrue(db.get_user_by_username("legacy")["password_hash"].startswith("$argon2"))
def test_auth_version_revokes_existing_token(self) -> None:
db.create_user("viewer", "Example-password123!")
user = db.get_user_by_username("viewer")
token = create_access_token(
"viewer", "user", auth_version=int(user["auth_version"])
)
self.assertEqual(_load_current_user_from_token(token)["username"], "viewer")
db.increment_user_auth_version("viewer")
with self.assertRaises(HTTPException) as context:
_load_current_user_from_token(token)
self.assertEqual(context.exception.status_code, 401)
async def test_request_mutations_require_owner_or_admin(self) -> None:
runtime = SimpleNamespace(
jellyseerr_base_url="http://seerr.test", jellyseerr_api_key="secret"
)
client = SimpleNamespace(
configured=lambda: True,
get_request=AsyncMock(
return_value={"id": 42, "requestedBy": {"username": "owner"}}
),
)
with patch.object(requests_router, "JellyseerrClient", return_value=client):
with self.assertRaises(HTTPException) as context:
await requests_router._ensure_request_mutation_access(
runtime, 42, {"username": "someone-else", "role": "user"}
)
self.assertEqual(context.exception.status_code, 403)
owned = await requests_router._ensure_request_mutation_access(
runtime, 42, {"username": "owner", "role": "user"}
)
self.assertEqual(owned["id"], 42)
self.assertIsNone(
await requests_router._ensure_request_mutation_access(
SimpleNamespace(), 42, {"username": "admin", "role": "admin"}
)
)
def test_account_deletion_removes_or_anonymizes_personal_data(self) -> None:
db.create_user(
"viewer", "Example-password123!", email="viewer@example.test"
)
user = db.get_user_by_username("viewer")
now = "2026-09-17T00:00:00+00:00"
db.upsert_request_cache(
42,
99,
"movie",
2,
"Example",
2026,
"viewer",
"viewer",
int(user["id"]),
now,
now,
'{"requestedBy":{"username":"viewer","email":"viewer@example.test"}}',
)
with db._connect() as conn:
conn.execute(
"INSERT INTO snapshots (request_id, state, created_at, payload_json) VALUES (?, ?, ?, ?)",
(
"42",
"available",
now,
'{"requestedBy":{"username":"viewer","email":"viewer@example.test"}}',
),
)
db.save_action("42", "created", "Created", "ok", "Created by viewer")
item = db.create_portal_item(
kind="issue",
title="Example",
description="Example",
created_by_username="viewer",
created_by_id=int(user["id"]),
)
result = db.delete_user_data_by_username("viewer")
self.assertTrue(result["deleted"])
self.assertIsNone(db.get_user_by_username("viewer"))
with db._connect() as conn:
request_row = conn.execute(
"SELECT requested_by, requested_by_id, payload_json FROM requests_cache WHERE request_id = 42"
).fetchone()
snapshot_json = conn.execute(
"SELECT payload_json FROM snapshots WHERE request_id = '42'"
).fetchone()[0]
action_message = conn.execute(
"SELECT message FROM actions WHERE request_id = '42'"
).fetchone()[0]
portal_owner = conn.execute(
"SELECT created_by_username, created_by_id FROM portal_items WHERE id = ?",
(item["id"],),
).fetchone()
self.assertEqual(request_row[0], "Deleted user")
self.assertIsNone(request_row[1])
self.assertNotIn("viewer", request_row[2].lower())
self.assertNotIn("viewer", snapshot_json.lower())
self.assertNotIn("viewer", action_message.lower())
self.assertTrue(portal_owner[0].startswith("deleted-user-"))
self.assertIsNone(portal_owner[1])
async def test_branding_upload_rejects_oversized_images_before_decode(self) -> None:
upload = SimpleNamespace(
filename="logo.png",
content_type="image/png",
read=AsyncMock(return_value=b"x" * (5 * 1024 * 1024 + 1)),
)
with self.assertRaises(HTTPException) as context:
await branding_router.save_branding_image(upload)
self.assertEqual(context.exception.status_code, 413)
upload.read.assert_awaited_once_with(5 * 1024 * 1024 + 1)
class NetworkSecurityTests(unittest.TestCase): class NetworkSecurityTests(unittest.TestCase):
@@ -1208,6 +1402,13 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
secret = patch.object(settings, 'jwt_secret', 'manual-release-tests-secret-1234567890123456') secret = patch.object(settings, 'jwt_secret', 'manual-release-tests-secret-1234567890123456')
secret.start() secret.start()
self.addCleanup(secret.stop) self.addCleanup(secret.stop)
access = patch.object(
requests_router,
"_ensure_request_mutation_access",
new=AsyncMock(return_value=None),
)
access.start()
self.addCleanup(access.stop)
def selection(self, payload, request_id, source): def selection(self, payload, request_id, source):
payload['selectionToken'] = requests_router.manual_releases.issue_selection( payload['selectionToken'] = requests_router.manual_releases.issue_selection(
@@ -1628,6 +1829,16 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
super().setUp()
access = patch.object(
requests_router,
"_ensure_request_mutation_access",
new=AsyncMock(return_value=None),
)
access.start()
self.addCleanup(access.stop)
def test_failed_repair_marks_linked_issue_as_blocked(self) -> None: def test_failed_repair_marks_linked_issue_as_blocked(self) -> None:
issue = {"id": 12, "status": "in_progress"} issue = {"id": 12, "status": "in_progress"}
with ( with (
@@ -2072,6 +2283,11 @@ class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)), patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "BazarrClient", return_value=bazarr), patch.object(requests_router, "BazarrClient", return_value=bazarr),
patch.object(
requests_router,
"_ensure_request_mutation_access",
new=AsyncMock(return_value=None),
),
patch.object(requests_router, "save_action"), patch.object(requests_router, "save_action"),
patch.object(requests_router, "get_portal_item", return_value={ patch.object(requests_router, "get_portal_item", return_value={
"id": 12, "id": 12,
@@ -2184,28 +2400,28 @@ class InviteOperationalStateTests(TempDatabaseMixin, unittest.IsolatedAsyncioTes
async def test_invite_list_reports_automatic_operational_states(self) -> None: async def test_invite_list_reports_automatic_operational_states(self) -> None:
ready = db.create_signup_invite(code="READY", recipient_email="ready@example.com") ready = db.create_signup_invite(code="READY", recipient_email="ready@example.com")
db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com") disabled = db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com")
used = db.create_signup_invite(code="USED", max_uses=1, recipient_email="used@example.com") used = db.create_signup_invite(code="USED", max_uses=1, recipient_email="used@example.com")
db.increment_signup_invite_use(int(used["id"])) db.increment_signup_invite_use(int(used["id"]))
db.create_signup_invite( expired = db.create_signup_invite(
code="EXPIRED", code="EXPIRED",
expires_at="2000-01-01T00:00:00+00:00", expires_at="2000-01-01T00:00:00+00:00",
recipient_email="expired@example.com", recipient_email="expired@example.com",
) )
db.create_signup_invite( no_profile = db.create_signup_invite(
code="NO-PROFILE", code="NO-PROFILE",
profile_id=999, profile_id=999,
recipient_email="profile@example.com", recipient_email="profile@example.com",
) )
payload = await admin_router.get_invites() payload = await admin_router.get_invites()
states = {invite["code"]: invite["operational_state"] for invite in payload["invites"]} states = {invite["id"]: invite["operational_state"] for invite in payload["invites"]}
self.assertEqual(states[ready["code"]], "ready") self.assertEqual(states[ready["id"]], "ready")
self.assertEqual(states["DISABLED"], "disabled") self.assertEqual(states[disabled["id"]], "disabled")
self.assertEqual(states["USED"], "exhausted") self.assertEqual(states[used["id"]], "exhausted")
self.assertEqual(states["EXPIRED"], "expired") self.assertEqual(states[expired["id"]], "expired")
self.assertEqual(states["NO-PROFILE"], "profile_unavailable") self.assertEqual(states[no_profile["id"]], "profile_unavailable")
self.assertEqual(payload["summary"]["total"], 5) self.assertEqual(payload["summary"]["total"], 5)
self.assertEqual(payload["summary"]["ready"], 1) self.assertEqual(payload["summary"]["ready"], 1)
self.assertEqual(payload["summary"]["attention"], 4) self.assertEqual(payload["summary"]["attention"], 4)
+8 -1
View File
@@ -1,5 +1,5 @@
import unittest import unittest
from unittest.mock import patch from unittest.mock import AsyncMock, patch
from backend.app.config import settings from backend.app.config import settings
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -16,6 +16,13 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
secret = patch.object(settings, "jwt_secret", "feature-access-tests-only-secret-123456789") secret = patch.object(settings, "jwt_secret", "feature-access-tests-only-secret-123456789")
secret.start() secret.start()
self.addCleanup(secret.stop) self.addCleanup(secret.stop)
access = patch.object(
requests,
"_ensure_request_mutation_access",
new=AsyncMock(return_value=None),
)
access.start()
self.addCleanup(access.stop)
db.create_user('feature-viewer', 'Example-password123!', role='user') db.create_user('feature-viewer', 'Example-password123!', role='user')
db.create_user('feature-admin', 'Example-password123!', role='admin') db.create_user('feature-admin', 'Example-password123!', role='admin')
self.user = db.get_user_by_username('feature-viewer') self.user = db.get_user_by_username('feature-viewer')
+9
View File
@@ -61,6 +61,15 @@ class ManualPermissionTests(TempDatabaseMixin, unittest.TestCase):
class ManualEpisodeSearchTests(unittest.IsolatedAsyncioTestCase): class ManualEpisodeSearchTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
access = patch.object(
requests,
'_ensure_request_mutation_access',
new=AsyncMock(return_value=None),
)
access.start()
self.addCleanup(access.stop)
async def test_episode_batch_is_bounded_and_exposes_next_page(self): async def test_episode_batch_is_bounded_and_exposes_next_page(self):
episodes = [{'id': i, 'seasonNumber': 1, 'monitored': True, 'hasFile': False} for i in range(1, 26)] episodes = [{'id': i, 'seasonNumber': 1, 'monitored': True, 'hasFile': False} for i in range(1, 26)]
episodes += [{'id': 26, 'seasonNumber': 1, 'monitored': True, 'hasFile': True}] episodes += [{'id': 26, 'seasonNumber': 1, 'monitored': True, 'hasFile': True}]
+9
View File
@@ -15,6 +15,8 @@ services:
AUTH_COOKIE_NAME: magent_beta_auth AUTH_COOKIE_NAME: magent_beta_auth
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
AUTH_COOKIE_SECURE: "true"
AUTH_COOKIE_SAMESITE: strict
SQLITE_PATH: /app/data/magent.db SQLITE_PATH: /app/data/magent.db
LOG_FILE: /app/data/magent.log LOG_FILE: /app/data/magent.log
SITE_BANNER_ENABLED: "true" SITE_BANNER_ENABLED: "true"
@@ -26,3 +28,10 @@ services:
volumes: volumes:
- ./data:/app/data - ./data:/app/data
restart: unless-stopped restart: unless-stopped
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
+8 -1
View File
@@ -5,6 +5,13 @@ services:
- ./.env - ./.env
ports: ports:
- "3000:3000" - "3000:3000"
- "8000:8000" - "127.0.0.1:8000:8000"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
+10
View File
@@ -5,9 +5,19 @@ services:
build: . build: .
env_file: env_file:
- ./.env - ./.env
environment:
AUTH_COOKIE_SECURE: "true"
AUTH_COOKIE_SAMESITE: strict
ports: ports:
- "10.30.1.32:3200:3000" - "10.30.1.32:3200:3000"
- "127.0.0.1:8200:8000" - "127.0.0.1:8200:8000"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
restart: unless-stopped restart: unless-stopped
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
+8 -1
View File
@@ -7,6 +7,13 @@ services:
- ./.env - ./.env
ports: ports:
- "3000:3000" - "3000:3000"
- "8000:8000" - "127.0.0.1:8000:8000"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
+18 -4
View File
@@ -36,6 +36,7 @@ type Profile = {
type Invite = { type Invite = {
id: number id: number
code: string code: string
code_available?: boolean
label?: string | null label?: string | null
description?: string | null description?: string | null
profile_id?: number | null profile_id?: number | null
@@ -501,17 +502,30 @@ export default function AdminInviteManagementPage() {
} }
const copyInviteLink = async (invite: Invite) => { const copyInviteLink = async (invite: Invite) => {
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
try { try {
let usableInvite = invite
if (!invite.code_available) {
const response = await authFetch(`${getApiBase()}/admin/invites/${invite.id}/rotate`, {
method: 'POST',
})
if (!response.ok) {
if (handleAuthResponse(response)) return
throw new Error((await response.text()) || 'Could not generate a replacement link.')
}
const data = await response.json()
usableInvite = data.invite as Invite
setInvites((current) => current.map((item) => item.id === invite.id ? usableInvite : item))
}
const url = `${signupBaseUrl}?code=${encodeURIComponent(usableInvite.code)}`
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url) await navigator.clipboard.writeText(url)
setStatus(`Copied invite link for ${invite.code}.`) setStatus(`Copied the invite link. Keep it safe; Magent will not display it again after this page reloads.`)
} else { } else {
window.prompt('Copy invite link', url) window.prompt('Copy invite link', url)
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)
window.prompt('Copy invite link', url) setError(err instanceof Error ? err.message : 'Could not generate or copy the invite link.')
} }
} }
@@ -1666,7 +1680,7 @@ export default function AdminInviteManagementPage() {
</div> </div>
<div className="admin-inline-actions"> <div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}> <button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}>
Copy link {invite.code_available ? 'Copy link' : 'Generate replacement link'}
</button> </button>
<button <button
type="button" type="button"
+15 -4
View File
@@ -11,6 +11,7 @@ import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean } type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean }
type OwnedInvite = { type OwnedInvite = {
id: number; code: string; label?: string | null; description?: string | null id: number; code: string; label?: string | null; description?: string | null
code_available?: boolean
recipient_email?: string | null; max_uses?: number | null; use_count: number recipient_email?: string | null; max_uses?: number | null; use_count: number
remaining_uses?: number | null; enabled: boolean; expires_at?: string | null remaining_uses?: number | null; enabled: boolean; expires_at?: string | null
is_usable?: boolean; created_at?: string | null is_usable?: boolean; created_at?: string | null
@@ -212,12 +213,22 @@ export default function ProfileInvitesPage() {
} }
const copyInviteLink = async (invite: OwnedInvite) => { const copyInviteLink = async (invite: OwnedInvite) => {
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
try { try {
let usableInvite = invite
if (!invite.code_available) {
const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}/rotate`, {
method: 'POST',
})
if (!response.ok) throw new Error((await response.text()) || 'Could not generate a replacement link.')
const data = await response.json()
usableInvite = data.invite as OwnedInvite
setInvites((current) => current.map((item) => item.id === invite.id ? usableInvite : item))
}
const url = `${signupBaseUrl}?code=${encodeURIComponent(usableInvite.code)}`
await navigator.clipboard.writeText(url) await navigator.clipboard.writeText(url)
setStatus(`Copied the link for ${invite.label || invite.code}.`) setStatus(`Copied the link for ${invite.label || usableInvite.code}. Keep it safe; Magent will not display it again after this page reloads.`)
} catch { } catch {
window.prompt('Copy invite link', url) setError('Could not generate or copy the invite link.')
} }
} }
@@ -297,7 +308,7 @@ export default function ProfileInvitesPage() {
<div className="profile-invites-list"> <div className="profile-invites-list">
<div className="invite-flow-heading"><div><span className="eyebrow">Your invites</span><h2>Created invites</h2><p className="lede">Copy, edit, disable, or remove invitations you have made.</p></div></div> <div className="invite-flow-heading"><div><span className="eyebrow">Your invites</span><h2>Created invites</h2><p className="lede">Copy, edit, disable, or remove invitations you have made.</p></div></div>
{invites.length === 0 ? <div className="status-banner">You have not created any invites yet.</div> : <div className="admin-list">{invites.map((invite) => <div key={invite.id} className="admin-list-item"><div className="admin-list-item-main"><div className="admin-list-item-title-row"><strong>{invite.label || 'Unnamed invite'}</strong><code className="invite-code">{invite.code}</code><span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>{invite.is_usable ? 'Ready' : 'Unavailable'}</span></div>{invite.description && <p className="admin-list-item-text admin-list-item-text--muted">{invite.description}</p>}<div className="admin-meta-row"><span>Delivery: {invite.recipient_email || 'Manual link'}</span><span>Uses: {invite.use_count}{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}</span><span>Expires: {formatDate(invite.expires_at)}</span><span>Created: {formatDate(invite.created_at)}</span></div></div><div className="admin-inline-actions"><button type="button" className="ghost-button" onClick={() => void copyInviteLink(invite)}>Copy link</button><button type="button" className="ghost-button" onClick={() => editInvite(invite)}>Edit</button><button type="button" onClick={() => void deleteInvite(invite)}>Delete</button></div></div>)}</div>} {invites.length === 0 ? <div className="status-banner">You have not created any invites yet.</div> : <div className="admin-list">{invites.map((invite) => <div key={invite.id} className="admin-list-item"><div className="admin-list-item-main"><div className="admin-list-item-title-row"><strong>{invite.label || 'Unnamed invite'}</strong><code className="invite-code">{invite.code}</code><span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>{invite.is_usable ? 'Ready' : 'Unavailable'}</span></div>{invite.description && <p className="admin-list-item-text admin-list-item-text--muted">{invite.description}</p>}<div className="admin-meta-row"><span>Delivery: {invite.recipient_email || 'Manual link'}</span><span>Uses: {invite.use_count}{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}</span><span>Expires: {formatDate(invite.expires_at)}</span><span>Created: {formatDate(invite.created_at)}</span></div></div><div className="admin-inline-actions"><button type="button" className="ghost-button" onClick={() => void copyInviteLink(invite)}>{invite.code_available ? 'Copy link' : 'Generate replacement link'}</button><button type="button" className="ghost-button" onClick={() => editInvite(invite)}>Edit</button><button type="button" onClick={() => void deleteInvite(invite)}>Delete</button></div></div>)}</div>}
</div> </div>
</section> </section>
)} )}
+20
View File
@@ -2,7 +2,27 @@ const backendUrl = process.env.BACKEND_INTERNAL_URL || 'http://backend:8000'
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
poweredByHeader: false,
compress: true,
experimental: { proxyTimeout: 180000 }, experimental: { proxyTimeout: 180000 },
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'no-referrer' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
},
{
source: '/login',
headers: [{ key: 'Cache-Control', value: 'private, no-store, max-age=0' }],
},
]
},
async rewrites() { async rewrites() {
return [ return [
{ {
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server'
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const developmentEval = process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''
const csp = [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${developmentEval}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
"connect-src 'self'",
"worker-src 'self' blob:",
"manifest-src 'self'",
"upgrade-insecure-requests",
].join('; ')
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
requestHeaders.set('Content-Security-Policy', csp)
const response = NextResponse.next({ request: { headers: requestHeaders } })
response.headers.set('Content-Security-Policy', csp)
return response
}
export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico|branding/).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}
+18 -2
View File
@@ -7,7 +7,7 @@ cd "$repo_root"
deploy_host="${DEPLOY_HOST:-AMS-DEV01}" deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
deploy_user="${DEPLOY_USER:-zak}" deploy_user="${DEPLOY_USER:-zak}"
deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}" deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}"
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}" ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=yes"}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)" timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
remote="${deploy_user}@${deploy_host}" remote="${deploy_user}@${deploy_host}"
@@ -16,9 +16,12 @@ echo "Deploying tracked repository contents to ${remote}:${deploy_path}"
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" " git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
set -e set -e
umask 077
mkdir -p '${deploy_path}' mkdir -p '${deploy_path}'
chmod 700 '${deploy_path}'
backup_root=\"\${HOME}/magent-backups/${timestamp}\" backup_root=\"\${HOME}/magent-backups/${timestamp}\"
mkdir -p \"\${backup_root}\" mkdir -p \"\${backup_root}\"
chmod 700 \"\${backup_root}\"
cd '${deploy_path}' cd '${deploy_path}'
for path in backend frontend docker-compose.yml docker-compose.hub.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do for path in backend frontend docker-compose.yml docker-compose.hub.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
if [ -e \"\$path\" ]; then if [ -e \"\$path\" ]; then
@@ -26,7 +29,20 @@ git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
fi fi
done done
tar -xf - -C '${deploy_path}' tar -xf - -C '${deploy_path}'
docker compose up -d --build if [ -f '${deploy_path}/.env' ]; then
chmod 600 '${deploy_path}/.env'
fi
mkdir -p '${deploy_path}/data'
chmod 700 '${deploy_path}/data'
docker compose build
if ! grep -Eq '^[[:space:]]*SETTINGS_ENCRYPTION_KEY=' .env; then
settings_key=\"\$(docker compose run --rm --no-deps --entrypoint python magent -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')\"
printf '\nSETTINGS_ENCRYPTION_KEY=%s\n' \"\${settings_key}\" >> .env
chmod 600 .env
fi
docker compose run --rm --no-deps --entrypoint python magent -c \"from app.config import settings; from app.secret_storage import validate_secret_storage_configuration; assert len(str(settings.jwt_secret or '').strip()) >= 32, 'JWT_SECRET must contain at least 32 characters'; validate_secret_storage_configuration()\"
docker compose run --rm --user 0 magent chown -R 1000:1000 /app/data
docker compose up -d
" "
echo "Running remote smoke checks" echo "Running remote smoke checks"
+15 -1
View File
@@ -9,7 +9,7 @@ deploy_user="${DEPLOY_USER:-zak}"
prod_path="${PROD_DEPLOY_PATH:-/home/${deploy_user}/magent}" prod_path="${PROD_DEPLOY_PATH:-/home/${deploy_user}/magent}"
deploy_path="${BETA_DEPLOY_PATH:-/home/${deploy_user}/magent-beta}" deploy_path="${BETA_DEPLOY_PATH:-/home/${deploy_user}/magent-beta}"
beta_frontend_bind="${BETA_FRONTEND_BIND:-10.30.1.32}" beta_frontend_bind="${BETA_FRONTEND_BIND:-10.30.1.32}"
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}" ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=yes"}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)" timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
remote="${deploy_user}@${deploy_host}" remote="${deploy_user}@${deploy_host}"
@@ -18,9 +18,12 @@ echo "Deploying tracked beta repository contents to ${remote}:${deploy_path}"
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" " git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
set -e set -e
umask 077
mkdir -p '${deploy_path}' mkdir -p '${deploy_path}'
chmod 700 '${deploy_path}'
backup_root=\"\${HOME}/magent-beta-backups/${timestamp}\" backup_root=\"\${HOME}/magent-beta-backups/${timestamp}\"
mkdir -p \"\${backup_root}\" mkdir -p \"\${backup_root}\"
chmod 700 \"\${backup_root}\"
cd '${deploy_path}' cd '${deploy_path}'
for path in backend frontend docker-compose.yml docker-compose.hub.yml docker-compose.beta.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do for path in backend frontend docker-compose.yml docker-compose.hub.yml docker-compose.beta.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
if [ -e \"\$path\" ]; then if [ -e \"\$path\" ]; then
@@ -32,14 +35,25 @@ git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
if [ ! -f '${deploy_path}/.env' ] && [ -f '${prod_path}/.env' ]; then if [ ! -f '${deploy_path}/.env' ] && [ -f '${prod_path}/.env' ]; then
cp '${prod_path}/.env' '${deploy_path}/.env' cp '${prod_path}/.env' '${deploy_path}/.env'
fi fi
if [ -f '${deploy_path}/.env' ]; then
chmod 600 '${deploy_path}/.env'
fi
mkdir -p '${deploy_path}/data' mkdir -p '${deploy_path}/data'
chmod 700 '${deploy_path}/data'
if [ ! -f '${deploy_path}/data/magent.db' ] && [ -d '${prod_path}/data' ]; then if [ ! -f '${deploy_path}/data/magent.db' ] && [ -d '${prod_path}/data' ]; then
cp -a '${prod_path}/data/.' '${deploy_path}/data/' cp -a '${prod_path}/data/.' '${deploy_path}/data/'
fi fi
cd '${deploy_path}' cd '${deploy_path}'
docker compose -p magent-beta -f docker-compose.beta.yml build docker compose -p magent-beta -f docker-compose.beta.yml build
if ! grep -Eq '^[[:space:]]*SETTINGS_ENCRYPTION_KEY=' .env; then
settings_key=\"\$(docker compose -p magent-beta -f docker-compose.beta.yml run --rm --no-deps --entrypoint python magent -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')\"
printf '\nSETTINGS_ENCRYPTION_KEY=%s\n' \"\${settings_key}\" >> .env
chmod 600 .env
fi
docker compose -p magent-beta -f docker-compose.beta.yml run --rm --no-deps --entrypoint python magent -c \"from app.config import settings; from app.secret_storage import validate_secret_storage_configuration; assert len(str(settings.jwt_secret or '').strip()) >= 32, 'JWT_SECRET must contain at least 32 characters'; validate_secret_storage_configuration()\"
docker compose -p magent-beta -f docker-compose.beta.yml run --rm --user 0 magent chown -R 1000:1000 /app/data
docker compose -p magent-beta -f docker-compose.beta.yml up -d docker compose -p magent-beta -f docker-compose.beta.yml up -d
" "
+3
View File
@@ -9,6 +9,8 @@ from pathlib import Path
import secrets import secrets
import sys import sys
from cryptography.fernet import Fernet
from app.runtime import get_runtime_settings from app.runtime import get_runtime_settings
@@ -30,6 +32,7 @@ def prepare(destination: Path) -> None:
password = secrets.token_urlsafe(30) password = secrets.token_urlsafe(30)
values.update( values.update(
APP_NAME='Magent', JWT_SECRET=secrets.token_urlsafe(48), APP_NAME='Magent', JWT_SECRET=secrets.token_urlsafe(48),
SETTINGS_ENCRYPTION_KEY=Fernet.generate_key().decode('ascii'),
ADMIN_USERNAME='admin', ADMIN_PASSWORD=password, ADMIN_USERNAME='admin', ADMIN_PASSWORD=password,
AUTH_COOKIE_SECURE=True, AUTH_COOKIE_DOMAIN='magent.grizzlyflix.co.nz', AUTH_COOKIE_SECURE=True, AUTH_COOKIE_DOMAIN='magent.grizzlyflix.co.nz',
AUTH_COOKIE_NAME='magent_auth', AUTH_STATE_COOKIE_NAME='magent_logged_in', AUTH_COOKIE_NAME='magent_auth', AUTH_STATE_COOKIE_NAME='magent_logged_in',