From 5f2dc52771450e1169259cc151c4b4cee5876247 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Mon, 2 Mar 2026 20:44:58 +1300 Subject: [PATCH] Hotfix: add logged-out password reset flow --- .build_number | 2 +- backend/app/build_info.py | 4 +- backend/app/db.py | 200 +++++++++++++++ backend/app/routers/auth.py | 110 ++++++++- backend/app/services/invite_email.py | 60 +++++ backend/app/services/password_reset.py | 330 +++++++++++++++++++++++++ frontend/app/forgot-password/page.tsx | 79 ++++++ frontend/app/login/page.tsx | 3 + frontend/app/reset-password/page.tsx | 156 ++++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- 11 files changed, 943 insertions(+), 7 deletions(-) create mode 100644 backend/app/services/password_reset.py create mode 100644 frontend/app/forgot-password/page.tsx create mode 100644 frontend/app/reset-password/page.tsx diff --git a/.build_number b/.build_number index 333751a..ef5618c 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -0203261953 +0203262044 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 9b0ceb7..4c1609c 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "0203261953" -CHANGELOG = '2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' +BUILD_NUMBER = "0203262044" +CHANGELOG = '2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' diff --git a/backend/app/db.py b/backend/app/db.py index 5ff4668..cf1685c 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -2,6 +2,7 @@ import json import os import sqlite3 import logging +from hashlib import sha256 from datetime import datetime, timezone, timedelta from typing import Any, Dict, Optional @@ -292,6 +293,22 @@ def init_db() -> None: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + username TEXT NOT NULL, + recipient_email TEXT NOT NULL, + auth_provider TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + requested_by_ip TEXT, + requested_user_agent TEXT + ) + """ + ) conn.execute( """ CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at @@ -316,6 +333,18 @@ def init_db() -> None: ON seerr_media_failures (suppress_until) """ ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_username + ON password_reset_tokens (username) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at + ON password_reset_tokens (expires_at) + """ + ) conn.execute( """ CREATE TABLE IF NOT EXISTS user_activity ( @@ -679,6 +708,45 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]: } +def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any]]: + with _connect() as conn: + row = conn.execute( + """ + SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, + created_at, last_login_at, is_blocked, auto_search_enabled, + invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, + jellyfin_password_hash, last_jellyfin_auth_at + FROM users + WHERE jellyseerr_user_id = ? + ORDER BY id ASC + LIMIT 1 + """, + (jellyseerr_user_id,), + ).fetchone() + if not row: + return None + return { + "id": row[0], + "username": row[1], + "password_hash": row[2], + "role": row[3], + "auth_provider": row[4], + "jellyseerr_user_id": row[5], + "created_at": row[6], + "last_login_at": row[7], + "is_blocked": bool(row[8]), + "auto_search_enabled": bool(row[9]), + "invite_management_enabled": bool(row[10]), + "profile_id": row[11], + "expires_at": row[12], + "invited_by_code": row[13], + "invited_at": row[14], + "is_expired": _is_datetime_in_past(row[12]), + "jellyfin_password_hash": row[15], + "last_jellyfin_auth_at": row[16], + } + + def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]: with _connect() as conn: row = conn.execute( @@ -2271,6 +2339,138 @@ def get_settings_overrides() -> Dict[str, str]: return overrides +def _hash_password_reset_token(token_value: str) -> str: + return sha256(str(token_value).encode("utf-8")).hexdigest() + + +def _password_reset_token_row_to_dict(row: Any) -> Dict[str, Any]: + return { + "id": row[0], + "token_hash": row[1], + "username": row[2], + "recipient_email": row[3], + "auth_provider": row[4], + "created_at": row[5], + "expires_at": row[6], + "used_at": row[7], + "requested_by_ip": row[8], + "requested_user_agent": row[9], + "is_expired": _is_datetime_in_past(row[6]), + "is_used": bool(row[7]), + } + + +def delete_expired_password_reset_tokens() -> int: + now_iso = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + cursor = conn.execute( + """ + DELETE FROM password_reset_tokens + WHERE expires_at <= ? OR used_at IS NOT NULL + """, + (now_iso,), + ) + return int(cursor.rowcount or 0) + + +def create_password_reset_token( + token_value: str, + username: str, + recipient_email: str, + auth_provider: str, + expires_at: str, + *, + requested_by_ip: Optional[str] = None, + requested_user_agent: Optional[str] = None, +) -> Dict[str, Any]: + created_at = datetime.now(timezone.utc).isoformat() + token_hash = _hash_password_reset_token(token_value) + delete_expired_password_reset_tokens() + with _connect() as conn: + conn.execute( + """ + DELETE FROM password_reset_tokens + WHERE username = ? AND used_at IS NULL + """, + (username,), + ) + conn.execute( + """ + INSERT INTO password_reset_tokens ( + token_hash, + username, + recipient_email, + auth_provider, + created_at, + expires_at, + used_at, + requested_by_ip, + requested_user_agent + ) + VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?) + """, + ( + token_hash, + username, + recipient_email, + auth_provider, + created_at, + expires_at, + requested_by_ip, + requested_user_agent, + ), + ) + logger.info( + "password reset token created username=%s provider=%s recipient=%s expires_at=%s requester_ip=%s", + username, + auth_provider, + recipient_email, + expires_at, + requested_by_ip, + ) + return { + "username": username, + "recipient_email": recipient_email, + "auth_provider": auth_provider, + "created_at": created_at, + "expires_at": expires_at, + "requested_by_ip": requested_by_ip, + "requested_user_agent": requested_user_agent, + } + + +def get_password_reset_token(token_value: str) -> Optional[Dict[str, Any]]: + token_hash = _hash_password_reset_token(token_value) + with _connect() as conn: + row = conn.execute( + """ + SELECT id, token_hash, username, recipient_email, auth_provider, created_at, + expires_at, used_at, requested_by_ip, requested_user_agent + FROM password_reset_tokens + WHERE token_hash = ? + """, + (token_hash,), + ).fetchone() + if not row: + return None + return _password_reset_token_row_to_dict(row) + + +def mark_password_reset_token_used(token_value: str) -> None: + token_hash = _hash_password_reset_token(token_value) + used_at = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + conn.execute( + """ + UPDATE password_reset_tokens + SET used_at = ? + WHERE token_hash = ? AND used_at IS NULL + """, + (used_at, token_hash), + ) + logger.info("password reset token marked used token_hash=%s", token_hash[:12]) + + def get_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> Optional[Dict[str, Any]]: if not media_type or not tmdb_id: return None diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index ef07ba3..0c32668 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -49,12 +49,21 @@ from ..services.user_cache import ( match_jellyseerr_user_id, save_jellyfin_users_cache, ) -from ..services.invite_email import send_templated_email +from ..services.invite_email import send_templated_email, smtp_email_config_ready +from ..services.password_reset import ( + PasswordResetUnavailableError, + apply_password_reset, + request_password_reset, + verify_password_reset_token, +) router = APIRouter(prefix="/auth", tags=["auth"]) logger = logging.getLogger(__name__) SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" STREAM_TOKEN_TTL_SECONDS = 120 +PASSWORD_RESET_GENERIC_MESSAGE = ( + "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) @@ -223,6 +232,11 @@ def _extract_http_error_detail(exc: Exception) -> str: return str(exc) +def _requested_user_agent(request: Request) -> str: + user_agent = request.headers.get("user-agent", "") + return user_agent[:512] + + async def _refresh_jellyfin_user_cache(client: JellyfinClient) -> None: try: users = await client.get_users() @@ -880,6 +894,100 @@ async def signup(payload: dict) -> dict: } +@router.post("/password/forgot") +async def forgot_password(payload: dict, request: Request) -> dict: + if not isinstance(payload, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") + identifier = payload.get("identifier") or payload.get("username") or payload.get("email") + if not isinstance(identifier, str) or not identifier.strip(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username or email is required.") + + ready, detail = smtp_email_config_ready() + if not ready: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Password reset email is unavailable: {detail}", + ) + + client_ip = _auth_client_ip(request) + safe_identifier = identifier.strip().lower()[:256] + logger.info("password reset requested identifier=%s client=%s", safe_identifier, client_ip) + try: + reset_result = await request_password_reset( + identifier, + requested_by_ip=client_ip, + requested_user_agent=_requested_user_agent(request), + ) + if reset_result.get("issued"): + logger.info( + "password reset issued username=%s provider=%s recipient=%s client=%s", + reset_result.get("username"), + reset_result.get("auth_provider"), + reset_result.get("recipient_email"), + client_ip, + ) + else: + logger.info( + "password reset request completed with no eligible account identifier=%s client=%s", + safe_identifier, + client_ip, + ) + except Exception as exc: + logger.warning( + "password reset email dispatch failed identifier=%s client=%s detail=%s", + safe_identifier, + client_ip, + str(exc), + ) + return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE} + + +@router.get("/password/reset/verify") +async def password_reset_verify(token: str) -> dict: + if not isinstance(token, str) or not token.strip(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.") + try: + return verify_password_reset_token(token.strip()) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +@router.post("/password/reset") +async def password_reset(payload: dict) -> dict: + if not isinstance(payload, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") + token = payload.get("token") + new_password = payload.get("new_password") + if not isinstance(token, str) or not token.strip(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.") + if not isinstance(new_password, str) or len(new_password.strip()) < 8: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password must be at least 8 characters.", + ) + + try: + result = await apply_password_reset(token.strip(), new_password.strip()) + except PasswordResetUnavailableError as exc: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except Exception as exc: + detail = _extract_http_error_detail(exc) + logger.warning("password reset failed token_present=%s detail=%s", bool(token), detail) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Password reset failed: {detail}", + ) from exc + + logger.info( + "password reset completed username=%s provider=%s", + result.get("username"), + result.get("provider"), + ) + return result + + @router.get("/profile") async def profile(current_user: dict = Depends(get_current_user)) -> dict: username = current_user.get("username") or "" diff --git a/backend/app/services/invite_email.py b/backend/app/services/invite_email.py index 9fe13ae..5ffe15c 100644 --- a/backend/app/services/invite_email.py +++ b/backend/app/services/invite_email.py @@ -537,3 +537,63 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st if warning: result["warning"] = warning return result + + +async def send_password_reset_email( + *, + recipient_email: str, + username: str, + token: str, + expires_at: str, + auth_provider: str, +) -> Dict[str, str]: + ready, detail = smtp_email_config_ready() + if not ready: + raise RuntimeError(detail) + + resolved_email = _normalize_email(recipient_email) + if not resolved_email: + raise RuntimeError("No valid recipient email is available for password reset.") + + app_url = _build_default_base_url() + reset_url = f"{app_url}/reset-password?token={token}" + provider_label = "Jellyfin, Seerr, and Magent" if auth_provider == "jellyfin" else "Magent" + subject = f"{env_settings.app_name} password reset" + body_text = ( + f"A password reset was requested for {username}.\n\n" + f"This link will reset the password used for {provider_label}.\n" + f"Reset link: {reset_url}\n" + f"Expires: {expires_at}\n\n" + "If you did not request this reset, you can ignore this email.\n" + ) + body_html = ( + f"

{html.escape(env_settings.app_name)} password reset

" + f"

A password reset was requested for {html.escape(username)}.

" + f"

This link will reset the password used for {html.escape(provider_label)}.

" + f"

Reset password

" + f"

Expires: {html.escape(expires_at)}

" + "

If you did not request this reset, you can ignore this email.

" + ) + + await asyncio.to_thread( + _send_email_sync, + recipient_email=resolved_email, + subject=subject, + body_text=body_text, + body_html=body_html, + ) + logger.info( + "Password reset email sent: username=%s recipient=%s provider=%s", + username, + resolved_email, + auth_provider, + ) + result = { + "recipient_email": resolved_email, + "subject": subject, + "reset_url": reset_url, + } + warning = smtp_email_delivery_warning() + if warning: + result["warning"] = warning + return result diff --git a/backend/app/services/password_reset.py b/backend/app/services/password_reset.py new file mode 100644 index 0000000..817b287 --- /dev/null +++ b/backend/app/services/password_reset.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import logging +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional + +from ..auth import normalize_user_auth_provider, resolve_user_auth_provider +from ..clients.jellyfin import JellyfinClient +from ..clients.jellyseerr import JellyseerrClient +from ..db import ( + create_password_reset_token, + delete_expired_password_reset_tokens, + get_password_reset_token, + get_user_by_jellyseerr_id, + get_user_by_username, + get_users_by_username_ci, + mark_password_reset_token_used, + set_user_auth_provider, + set_user_password, + sync_jellyfin_password_state, +) +from ..runtime import get_runtime_settings +from .invite_email import send_password_reset_email +from .user_cache import get_cached_jellyseerr_users, save_jellyseerr_users_cache + +logger = logging.getLogger(__name__) + +PASSWORD_RESET_TOKEN_TTL_MINUTES = 30 + + +class PasswordResetUnavailableError(RuntimeError): + pass + + +def _normalize_handles(value: object) -> list[str]: + if not isinstance(value, str): + return [] + normalized = value.strip().lower() + if not normalized: + return [] + handles = [normalized] + if "@" in normalized: + handles.append(normalized.split("@", 1)[0]) + return list(dict.fromkeys(handles)) + + +def _pick_preferred_user(users: list[dict], requested_identifier: str) -> dict | None: + if not users: + return None + requested = str(requested_identifier or "").strip().lower() + + def _rank(user: dict) -> tuple[int, int, int, int]: + provider = str(user.get("auth_provider") or "local").strip().lower() + role = str(user.get("role") or "user").strip().lower() + username = str(user.get("username") or "").strip().lower() + return ( + 0 if role == "admin" else 1, + 0 if isinstance(user.get("jellyseerr_user_id"), int) else 1, + 0 if provider == "jellyfin" else (1 if provider == "local" else 2), + 0 if username == requested else 1, + ) + + return sorted(users, key=_rank)[0] + + +def _find_matching_seerr_user(identifier: str, users: list[dict]) -> dict | None: + target_handles = set(_normalize_handles(identifier)) + if not target_handles: + return None + for user in users: + if not isinstance(user, dict): + continue + for key in ("username", "email"): + value = user.get(key) + if target_handles.intersection(_normalize_handles(value)): + return user + return None + + +async def _fetch_all_seerr_users() -> list[dict]: + cached = get_cached_jellyseerr_users() + if cached is not None: + return cached + runtime = get_runtime_settings() + client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) + if not client.configured(): + return [] + users: list[dict] = [] + take = 100 + skip = 0 + while True: + payload = await client.get_users(take=take, skip=skip) + if not payload: + break + if isinstance(payload, list): + batch = payload + elif isinstance(payload, dict): + batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items") + else: + batch = None + if not isinstance(batch, list) or not batch: + break + users.extend([user for user in batch if isinstance(user, dict)]) + if len(batch) < take: + break + skip += take + if users: + return save_jellyseerr_users_cache(users) + return users + + +def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]: + if isinstance(local_user, dict): + username = str(local_user.get("username") or "").strip() + if "@" in username: + return username + if isinstance(seerr_user, dict): + email = str(seerr_user.get("email") or "").strip() + if "@" in email: + return email + return None + + +async def _resolve_reset_target(identifier: str) -> Optional[Dict[str, Any]]: + normalized_identifier = str(identifier or "").strip() + if not normalized_identifier: + return None + + local_user = normalize_user_auth_provider( + _pick_preferred_user(get_users_by_username_ci(normalized_identifier), normalized_identifier) + ) + seerr_users: list[dict] | None = None + seerr_user: dict | None = None + + if isinstance(local_user, dict) and isinstance(local_user.get("jellyseerr_user_id"), int): + seerr_users = await _fetch_all_seerr_users() + seerr_user = next( + ( + user + for user in seerr_users + if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"]) + ), + None, + ) + + if not local_user: + seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users() + seerr_user = _find_matching_seerr_user(normalized_identifier, seerr_users) + if seerr_user: + seerr_user_id = seerr_user.get("id") or seerr_user.get("userId") or seerr_user.get("Id") + try: + seerr_user_id = int(seerr_user_id) if seerr_user_id is not None else None + except (TypeError, ValueError): + seerr_user_id = None + if seerr_user_id is not None: + local_user = normalize_user_auth_provider(get_user_by_jellyseerr_id(seerr_user_id)) + if not local_user: + for candidate in (seerr_user.get("email"), seerr_user.get("username")): + if not isinstance(candidate, str) or not candidate.strip(): + continue + local_user = normalize_user_auth_provider( + _pick_preferred_user(get_users_by_username_ci(candidate), candidate) + ) + if local_user: + break + + if not local_user: + return None + + auth_provider = resolve_user_auth_provider(local_user) + username = str(local_user.get("username") or "").strip() + recipient_email = _resolve_seerr_user_email(seerr_user, local_user) + if not recipient_email: + seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users() + if isinstance(local_user.get("jellyseerr_user_id"), int): + seerr_user = next( + ( + user + for user in seerr_users + if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"]) + ), + None, + ) + if not seerr_user: + seerr_user = _find_matching_seerr_user(username, seerr_users) + recipient_email = _resolve_seerr_user_email(seerr_user, local_user) + if not recipient_email: + return None + + if auth_provider == "jellyseerr": + runtime = get_runtime_settings() + jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) + if jellyfin_client.configured(): + try: + jellyfin_user = await jellyfin_client.find_user_by_name(username) + except Exception: + jellyfin_user = None + if isinstance(jellyfin_user, dict): + auth_provider = "jellyfin" + + if auth_provider not in {"local", "jellyfin"}: + return None + + return { + "username": username, + "recipient_email": recipient_email, + "auth_provider": auth_provider, + } + + +def _token_record_is_usable(record: Optional[dict]) -> bool: + if not isinstance(record, dict): + return False + if record.get("is_used"): + return False + if record.get("is_expired"): + return False + return True + + +def _mask_email(email: str) -> str: + candidate = str(email or "").strip() + if "@" not in candidate: + return "valid reset link" + local_part, domain = candidate.split("@", 1) + if not local_part: + return f"***@{domain}" + if len(local_part) == 1: + return f"{local_part}***@{domain}" + return f"{local_part[0]}***{local_part[-1]}@{domain}" + + +async def request_password_reset( + identifier: str, + *, + requested_by_ip: Optional[str] = None, + requested_user_agent: Optional[str] = None, +) -> Dict[str, Any]: + delete_expired_password_reset_tokens() + target = await _resolve_reset_target(identifier) + if not target: + logger.info("password reset requested with no eligible match identifier=%s", identifier.strip().lower()[:256]) + return {"status": "ok", "issued": False} + + token = secrets.token_urlsafe(32) + expires_at = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES)).isoformat() + create_password_reset_token( + token, + target["username"], + target["recipient_email"], + target["auth_provider"], + expires_at, + requested_by_ip=requested_by_ip, + requested_user_agent=requested_user_agent, + ) + await send_password_reset_email( + recipient_email=target["recipient_email"], + username=target["username"], + token=token, + expires_at=expires_at, + auth_provider=target["auth_provider"], + ) + return { + "status": "ok", + "issued": True, + "username": target["username"], + "recipient_email": target["recipient_email"], + "auth_provider": target["auth_provider"], + "expires_at": expires_at, + } + + +def verify_password_reset_token(token: str) -> Dict[str, Any]: + delete_expired_password_reset_tokens() + record = get_password_reset_token(token) + if not _token_record_is_usable(record): + raise ValueError("Password reset link is invalid or has expired.") + return { + "status": "ok", + "recipient_hint": _mask_email(str(record.get("recipient_email") or "")), + "auth_provider": record.get("auth_provider"), + "expires_at": record.get("expires_at"), + } + + +async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]: + delete_expired_password_reset_tokens() + record = get_password_reset_token(token) + if not _token_record_is_usable(record): + raise ValueError("Password reset link is invalid or has expired.") + + username = str(record.get("username") or "").strip() + if not username: + raise ValueError("Password reset link is invalid or has expired.") + + stored_user = normalize_user_auth_provider(get_user_by_username(username)) + if not stored_user: + raise ValueError("Password reset link is invalid or has expired.") + + auth_provider = resolve_user_auth_provider(stored_user) + if auth_provider == "jellyseerr": + auth_provider = "jellyfin" + + if auth_provider == "local": + set_user_password(username, new_password) + if str(stored_user.get("auth_provider") or "").strip().lower() != "local": + set_user_auth_provider(username, "local") + mark_password_reset_token_used(token) + logger.info("password reset applied username=%s provider=local", username) + return {"status": "ok", "provider": "local", "username": username} + + if auth_provider == "jellyfin": + runtime = get_runtime_settings() + client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) + if not client.configured(): + raise PasswordResetUnavailableError("Jellyfin is not configured for password reset.") + jellyfin_user = await client.find_user_by_name(username) + user_id = client._extract_user_id(jellyfin_user) + if not user_id: + raise ValueError("Password reset link is invalid or has expired.") + await client.set_user_password(user_id, new_password) + sync_jellyfin_password_state(username, new_password) + if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin": + set_user_auth_provider(username, "jellyfin") + mark_password_reset_token_used(token) + logger.info("password reset applied username=%s provider=jellyfin", username) + return {"status": "ok", "provider": "jellyfin", "username": username} + + raise ValueError("Password reset is not available for this sign-in provider.") diff --git a/frontend/app/forgot-password/page.tsx b/frontend/app/forgot-password/page.tsx new file mode 100644 index 0000000..024dd8d --- /dev/null +++ b/frontend/app/forgot-password/page.tsx @@ -0,0 +1,79 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import BrandingLogo from '../ui/BrandingLogo' +import { getApiBase } from '../lib/auth' + +export default function ForgotPasswordPage() { + const router = useRouter() + const [identifier, setIdentifier] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [status, setStatus] = useState(null) + + const submit = async (event: React.FormEvent) => { + event.preventDefault() + if (!identifier.trim()) { + setError('Enter your username or email.') + return + } + setLoading(true) + setError(null) + setStatus(null) + try { + const baseUrl = getApiBase() + const response = await fetch(`${baseUrl}/auth/password/forgot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifier: identifier.trim() }), + }) + const data = await response.json().catch(() => null) + if (!response.ok) { + throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to send reset link.') + } + setStatus( + typeof data?.message === 'string' + ? data.message + : 'If an account exists for that username or email, a password reset link has been sent.', + ) + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Unable to send reset link.') + } finally { + setLoading(false) + } + } + + return ( +
+ +

Forgot password

+

+ Enter the username or email you use for Jellyfin or Magent. If the account is eligible, a reset link + will be emailed to you. +

+
+ + {error &&
{error}
} + {status &&
{status}
} +
+ +
+ +
+
+ ) +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 6ecb9a0..638f0aa 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -85,6 +85,9 @@ export default function LoginPage() { > Sign in with Magent account + + Forgot password? + Have an invite? Create your account (Jellyfin + Magent) diff --git a/frontend/app/reset-password/page.tsx b/frontend/app/reset-password/page.tsx new file mode 100644 index 0000000..4b423a6 --- /dev/null +++ b/frontend/app/reset-password/page.tsx @@ -0,0 +1,156 @@ +'use client' + +import { Suspense, useEffect, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import BrandingLogo from '../ui/BrandingLogo' +import { getApiBase } from '../lib/auth' + +type ResetVerification = { + status: string + recipient_hint?: string + auth_provider?: string + expires_at?: string +} + +function ResetPasswordPageContent() { + const router = useRouter() + const searchParams = useSearchParams() + const token = searchParams.get('token') ?? '' + const [verification, setVerification] = useState(null) + const [loading, setLoading] = useState(false) + const [verifying, setVerifying] = useState(true) + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState(null) + const [status, setStatus] = useState(null) + + useEffect(() => { + const verifyToken = async () => { + if (!token) { + setError('Password reset link is invalid or missing.') + setVerifying(false) + return + } + setVerifying(true) + setError(null) + setStatus(null) + try { + const baseUrl = getApiBase() + const response = await fetch( + `${baseUrl}/auth/password/reset/verify?token=${encodeURIComponent(token)}`, + ) + const data = await response.json().catch(() => null) + if (!response.ok) { + throw new Error(typeof data?.detail === 'string' ? data.detail : 'Password reset link is invalid.') + } + setVerification(data) + } catch (err) { + console.error(err) + setVerification(null) + setError(err instanceof Error ? err.message : 'Password reset link is invalid.') + } finally { + setVerifying(false) + } + } + + void verifyToken() + }, [token]) + + const submit = async (event: React.FormEvent) => { + event.preventDefault() + if (!token) { + setError('Password reset link is invalid or missing.') + return + } + if (password.trim().length < 8) { + setError('Password must be at least 8 characters.') + return + } + if (password !== confirmPassword) { + setError('Passwords do not match.') + return + } + setLoading(true) + setError(null) + setStatus(null) + try { + const baseUrl = getApiBase() + const response = await fetch(`${baseUrl}/auth/password/reset`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, new_password: password }), + }) + const data = await response.json().catch(() => null) + if (!response.ok) { + throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to reset password.') + } + setStatus('Password updated. You can now sign in with the new password.') + setPassword('') + setConfirmPassword('') + window.setTimeout(() => router.push('/login'), 1200) + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Unable to reset password.') + } finally { + setLoading(false) + } + } + + const providerLabel = + verification?.auth_provider === 'jellyfin' ? 'Jellyfin, Seerr, and Magent' : 'Magent' + + return ( +
+ +

Reset password

+

Choose a new password for your account.

+
+ {verifying &&
Checking password reset link…
} + {!verifying && verification && ( +
+ This reset link was sent to {verification.recipient_hint || 'your email'} and will update the password + used for {providerLabel}. +
+ )} + + + {error &&
{error}
} + {status &&
{status}
} +
+ +
+ +
+
+ ) +} + +export default function ResetPasswordPage() { + return ( + Loading password reset…}> + + + ) +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2f92923..d286f9c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "magent-frontend", - "version": "0203261953", + "version": "0203262044", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "magent-frontend", - "version": "0203261953", + "version": "0203262044", "dependencies": { "next": "16.1.6", "react": "19.2.4", diff --git a/frontend/package.json b/frontend/package.json index 2908bd1..3d64de4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "magent-frontend", "private": true, - "version": "0203261953", + "version": "0203262044", "scripts": { "dev": "next dev", "build": "next build",