From 1bec270309403ce7e418278fbf04379268b62baa Mon Sep 17 00:00:00 2001 From: Mykal-Steele Date: Sun, 29 Mar 2026 05:22:57 +0700 Subject: [PATCH 1/3] fix: use bcrypt +hash instead of storing plain password (issue #19) --- config.py | 2 ++ main.py | 30 ++++++++++++++++++++++-------- requirements.txt | 1 + 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/config.py b/config.py index 05f36c6..9b76b29 100644 --- a/config.py +++ b/config.py @@ -18,7 +18,9 @@ CRON_SECRET = os.getenv("CRON_SECRET", "") ADMIN_SECRET = os.getenv("ADMIN_SECRET", "") ADMIN_USER = os.getenv("ADMIN_USER", "admin") +# ADMIN_PASSWORD legacy plaintext removed from active auth flow; keep only for backward compatibility in env migration scripts ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "") +ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "") # bcrypt hash ALERT_EMAIL = os.getenv("ALERT_EMAIL", "") # where to send error alerts (set to your email) BASE_URL = os.getenv("BASE_URL", "http://localhost:8000") diff --git a/main.py b/main.py index ebfafbc..5e53972 100644 --- a/main.py +++ b/main.py @@ -15,6 +15,7 @@ import hmac import time import collections +import bcrypt from datetime import datetime, timedelta from typing import List, Optional @@ -28,7 +29,7 @@ from scheduler_jobs import send_due_emails, generate_upcoming, sync_to_sheets, advance_processing from transcript import resolve_playlist from mailer import send_email as do_send_email, send_welcome_email -from config import CRON_SECRET, ADMIN_SECRET, ADMIN_USER, ADMIN_PASSWORD, SENTRY_DSN, CORS_ORIGINS +from config import CRON_SECRET, ADMIN_SECRET, ADMIN_USER, ADMIN_PASSWORD, ADMIN_PASSWORD_HASH, SENTRY_DSN, CORS_ORIGINS import sentry_sdk from sentry_sdk.integrations.fastapi import FastApiIntegration @@ -447,13 +448,26 @@ def _get_admin_token(request: Request) -> str: def _check_admin(request: Request): token = _get_admin_token(request) - if ADMIN_SECRET and token == ADMIN_SECRET: + if ADMIN_SECRET and hmac.compare_digest(token, ADMIN_SECRET): return if db.validate_admin_session(token): return raise HTTPException(401, "Unauthorized") +def _verify_admin_password(candidate: str) -> bool: + if not candidate: + return False + + if not ADMIN_PASSWORD_HASH: + return False + + try: + return bcrypt.checkpw(candidate.encode("utf-8"), ADMIN_PASSWORD_HASH.encode("utf-8")) + except (ValueError, TypeError): + return False + + # ── Auth ────────────────────────────────────────────────────────────────── class AdminLoginRequest(BaseModel): @@ -479,9 +493,7 @@ def admin_login(req: AdminLoginRequest, request: Request): raise HTTPException(429, "Too many login attempts. Try again in a minute.") attempts.append(now) - if not ADMIN_PASSWORD: - raise HTTPException(503, "Admin password not configured") - if req.username != ADMIN_USER or not hmac.compare_digest(req.password, ADMIN_PASSWORD): + if req.username != ADMIN_USER or not _verify_admin_password(req.password): raise HTTPException(401, "Invalid credentials") token = db.create_admin_session() db.prune_old_sessions() @@ -839,12 +851,14 @@ def admin_edit_transcript(video_id: str, body: TranscriptBody, request: Request) class IngestByYoutubeIdBody(BaseModel): youtube_id: str text: str - password: str + token: Optional[str] = None @app.post("/api/admin/ingest-by-youtube-id") def admin_ingest_by_youtube_id(body: IngestByYoutubeIdBody): - """Bookmarklet endpoint: accepts youtube_id + transcript text + admin password.""" - if not ADMIN_PASSWORD or not hmac.compare_digest(body.password, ADMIN_PASSWORD): + """Bookmarklet endpoint: accepts youtube_id + transcript text + admin token.""" + if not ADMIN_SECRET: + raise HTTPException(503, "Admin token is not configured") + if not body.token or not hmac.compare_digest(body.token, ADMIN_SECRET): raise HTTPException(401, "Unauthorized") text = body.text.strip() if len(text) < 100: diff --git a/requirements.txt b/requirements.txt index 88193d0..387c919 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,4 @@ google-auth apscheduler pytz sentry-sdk[fastapi] +bcrypt From c21dcf7f31bce4303e63ae8a541f345167bb47e7 Mon Sep 17 00:00:00 2001 From: Mykal-Steele Date: Sun, 29 Mar 2026 06:21:20 +0700 Subject: [PATCH 2/3] fix: add fallback for plain-pass comparison (Need to be remove after moving to ADMIN_PASSWORD_HASH) --- main.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 5e53972..9af64b4 100644 --- a/main.py +++ b/main.py @@ -459,13 +459,17 @@ def _verify_admin_password(candidate: str) -> bool: if not candidate: return False - if not ADMIN_PASSWORD_HASH: - return False + if ADMIN_PASSWORD_HASH: + try: + return bcrypt.checkpw(candidate.encode("utf-8"), ADMIN_PASSWORD_HASH.encode("utf-8")) + except (ValueError, TypeError): + return False - try: - return bcrypt.checkpw(candidate.encode("utf-8"), ADMIN_PASSWORD_HASH.encode("utf-8")) - except (ValueError, TypeError): - return False + # fallback if ADMIN_PASSWORD_HASH is not set + if ADMIN_PASSWORD: + return hmac.compare_digest(candidate, ADMIN_PASSWORD) + + return False # ── Auth ────────────────────────────────────────────────────────────────── @@ -493,7 +497,7 @@ def admin_login(req: AdminLoginRequest, request: Request): raise HTTPException(429, "Too many login attempts. Try again in a minute.") attempts.append(now) - if req.username != ADMIN_USER or not _verify_admin_password(req.password): + if not hmac.compare_digest(req.username, ADMIN_USER) or not _verify_admin_password(req.password): raise HTTPException(401, "Invalid credentials") token = db.create_admin_session() db.prune_old_sessions() From 5a32ea1c3eddd74bd61a97e885605f6865f08933 Mon Sep 17 00:00:00 2001 From: OakarOo <130909466+Mykal-Steele@users.noreply.github.com> Date: Sun, 29 Mar 2026 06:57:49 +0700 Subject: [PATCH 3/3] fix inconsistency with main.py --- config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/config.py b/config.py index 9b76b29..75323eb 100644 --- a/config.py +++ b/config.py @@ -18,7 +18,6 @@ CRON_SECRET = os.getenv("CRON_SECRET", "") ADMIN_SECRET = os.getenv("ADMIN_SECRET", "") ADMIN_USER = os.getenv("ADMIN_USER", "admin") -# ADMIN_PASSWORD legacy plaintext removed from active auth flow; keep only for backward compatibility in env migration scripts ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "") ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "") # bcrypt hash ALERT_EMAIL = os.getenv("ALERT_EMAIL", "") # where to send error alerts (set to your email)