From 4ae3a9eb176e91d0da252315159517efc229063a Mon Sep 17 00:00:00 2001 From: jerick Date: Tue, 27 Jan 2026 09:48:58 -0500 Subject: [PATCH] Authenticatoin Implementation --- README.md | 2 - __pycache__/config.cpython-311.pyc | Bin 2166 -> 2385 bytes config.py | 4 + main.py | 3 +- routers/__pycache__/auth.cpython-311.pyc | Bin 0 -> 7410 bytes routers/__pycache__/config.cpython-311.pyc | Bin 5697 -> 5876 bytes routers/__pycache__/pages.cpython-311.pyc | Bin 1415 -> 2321 bytes routers/auth.py | 164 ++++++++++++++++ routers/config.py | 13 +- routers/pages.py | 18 +- static/config.js | 42 +++- static/dashboard.js | 31 +++ templates/config.html | 19 ++ templates/dashboard.html | 1 + templates/login.html | 214 +++++++++++++++++++++ utils/__pycache__/auth.cpython-311.pyc | Bin 0 -> 1958 bytes utils/auth.py | 33 ++++ 17 files changed, 535 insertions(+), 9 deletions(-) create mode 100644 routers/__pycache__/auth.cpython-311.pyc create mode 100644 routers/auth.py create mode 100644 templates/login.html create mode 100644 utils/__pycache__/auth.cpython-311.pyc create mode 100644 utils/auth.py diff --git a/README.md b/README.md index c753add..91edfaa 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,6 @@ Features: ToDo: -- basic auth - - since control of the Discord bot would also be through there technically - Log output section on webpage, to see who is being assigned to who, when the hit is complete, if it is missed, how many hits a person has done - Hit Leaderboard? diff --git a/__pycache__/config.cpython-311.pyc b/__pycache__/config.cpython-311.pyc index 100ae0d70733fe8ca080b946ffb840442129e331..5aae51a85004af7261d1bbee74412f17d08b46a1 100644 GIT binary patch delta 254 zcmew+a8ZbFIWI340}!k|QjuxOHjz()NrGvk#y-Z0_aucyQbdm3#2dwGiXX|e#6Mj8hne_F*L*@KEN?JINU$T<(7D)e`ruVidejdYmnB{`lBHiND26sBMXO_9wmOw6oIn*5Vzu?H)z tWcUnHS|ki4esS33=BJeAq}ml}0=bMpT+F#yf`g5b`vQX~5EW?vg#g1z7K8u* diff --git a/config.py b/config.py index d921284..ddd330f 100644 --- a/config.py +++ b/config.py @@ -39,3 +39,7 @@ ASSIGNMENT_REMINDER = _config.get("ASSIGNMENT_REMINDER", 45) # Seconds before s # Chain Timer Settings CHAIN_TIMER_THRESHOLD = _config.get("CHAIN_TIMER_THRESHOLD", 5) # Minutes - start assigning hits when chain timer is at or below this + +# Authentication +AUTH_PASSWORD = _config.get("AUTH_PASSWORD", "YOUR_AUTH_PASSWORD_HERE") # Universal password for all users +JWT_SECRET = _config.get("JWT_SECRET", "your-secret-key-change-this") # Secret key for JWT tokens diff --git a/main.py b/main.py index 9c88d7a..4b1d9f7 100644 --- a/main.py +++ b/main.py @@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles from services.bot_assignment import BotAssignmentManager # Import routers -from routers import pages, factions, assignments, discord_mappings, config +from routers import pages, factions, assignments, discord_mappings, config, auth from routers import bot as bot_router @@ -24,6 +24,7 @@ app = FastAPI() app.mount("/static", StaticFiles(directory="static"), name="static") # Include all routers +app.include_router(auth.router) app.include_router(pages.router) app.include_router(factions.router) app.include_router(assignments.router) diff --git a/routers/__pycache__/auth.cpython-311.pyc b/routers/__pycache__/auth.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c0cfca8358ecb65253d6823e61b2a99a78a77003 GIT binary patch literal 7410 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 P6G&>LHc&$~Q_BAZlO3Z9 literal 0 HcmV?d00001 diff --git a/routers/__pycache__/config.cpython-311.pyc b/routers/__pycache__/config.cpython-311.pyc index 97308ebad904792fccd38ebec4a1a34e8e72a326..b41968aa46a0eb363e73843fe7a59af42d0df7d7 100644 GIT binary patch delta 696 zcmYL_Ur1AN6vyvxciX+*yZdK%yW4EJp*h!>+Z?F{%|JG%_~2|?iIj*nAEd&%Gbk~V ze8|^2uRdsm(u+_%1yP74K~P46V6_)P=q0#fFZI;RX)-!XYZ zk^}~0|J|LLE825;-7YwAyM%f83EtR$!3-x65vDlZMTL5lEGyzYvcRlLuxV+t8kgo; z;5!#0L2xQ=(0QFWA`Bwpp{mq>p*S=-+FvM)huc0w zkX6>0zIB!0V~wEyR<~$@g}3$)%u9RZK1|3T;Eycf@CULkX^q1raYclP5<(MlN{o## z4C-zG?Ksd}ldUaT&)XzhY11wC00)q`tx4+>gJ zdvMRr(H@G3f;aV~6pK`g7o~!yMT8cFUW6wKUY%E2I=uPb{4n!o-koRb%`tXM}2qpB{0K`~f4}sxl_N0`gsVH$JJPDI9IiMx6bowO@;v2gR0dCu)c*uEf zAE#h9Y2y)ocWRyp?1CW_!u+HN@of5od1`~pgL(JvaQES{NL!2avHj*+9}wGGa{cx- zpavar$kscDPgZT;J;rU<$e4Gnj zhO|)8qlE}5m)k-iv~SB%q?+vRdZ9Sz|nYo^ZD#K>}N?LwNTd&`0G?Q`smfs6C z{F`6CT7uIc6vibD*f!gJ>aYO@9H37lnERhBmrJghKZ&WLelr?}n2p GdhZ|e`-$uT diff --git a/routers/__pycache__/pages.cpython-311.pyc b/routers/__pycache__/pages.cpython-311.pyc index 9ee6ef8785c6a7737d2b2c85622f0aff905c0bc8..61101591f454b07f90ff34ab95472c409ad65fac 100644 GIT binary patch literal 2321 zcmb_cO-vg{6rNe{dTp=4HcFFXTDnyQ)CY{@kg7zHQ;O4K!cS#Lqgs}%Wp^+e)?RmZ z-85DXR$Hl5>LIsss(hkC;lMFRPC2hDOSM))s?f6r+D}IU4s6c^iInLkx(CSNBwWb zhq!(JEqj4`%<_Oo<`24I`dpf4-PFv)Ci zer;(ngN(9XGLRk4AVtMUHr<&Rv_lJO>5-IJL&dTtk-foucS_R>YKeFc;x2q%)+EEQdEEKB+#`!@kcb9+OZst*nEL<{p_qN^Pr+Gwam4fx z$8Mu?+V`BLbQPGc@Q7Ev&)baxKw%Z`DNy0-kuh&Iut<0BWbgz|y^52)>@l{df;C$( zOv$Vmd0AIbHQ<~wzG)UUOy#!6Vjp>7FExH*?68|TH>Z2h@igf1#FLe|+qnk@!ba{9 z!m7NLOX+f@h)SlBOQEljrkCj;w;{=+cm_Ny{2LAQ|-_Wy(1|Mzm(KdkG?W52;^|^ypD>Tyx%``(ZmT%@r zxb~Yc_Ny>f*ABio_m@9Yy5*3!-t<*A1*e8rKYfCu}gTEKv(&xPO-R{%^Gp}pV;i<=s(}avANTNCSI8= zp>wiRHZaZTxvY2HIyYvNc)U$||Bbm)-g`Huc%UBMux_Or!a`G6u-FAB&&#oh&FAxq zF6Z+$zafztQdTjoN|+|bj&!HuZm~2lmA_L*>>+otTWi%)0rNB}P%%}_h*QlvIC_l5>JpNT8@mn#I`*U!WNjez;p|YTNgbo zaL2mnX@R(P_Gy84t+UVX{-N#RA4Yy0sd-z$k?ocGmK97if{A7@QS-OGfbo4F{U*9S zU(eN|4VY-cM4RE@0^BLI0e$z|zdV4s+4E?J-Qc-;kOogS;Uo=ShC2^w*s>GsS{fYh jd7K8P>lzIvxHsDj3-807$8A90{gwt#_dNPnoCN;^7kn8( literal 1415 zcmZ`((Q6Y)7@yhQWRq@MwFrmpJ**G5&560WIlVZ4?aIZ{e7UHXOV?K~>r18i;^ks8RbeqLy6iydt-P!`!Zc&+3|L)9>dXBkGQ)=ci34V%fZ!^Br0JQkY@$!p|H zyt!~ZvByu?uCKGS*hu%g&~LcBPW=e_sI+G&Ix;GJKXbwL$fiT**pCn str: + """Get client IP address from request""" + # Check X-Forwarded-For header first (for proxy/load balancer) + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def is_locked_out(ip: str) -> bool: + """Check if IP is currently locked out""" + if ip not in failed_attempts: + return False + + attempt_data = failed_attempts[ip] + locked_until = attempt_data.get("locked_until") + + if not locked_until: + return False + + # Check if lockout has expired + if datetime.now() >= locked_until: + # Clear the lockout + del failed_attempts[ip] + return False + + return True + + +def record_failed_attempt(ip: str): + """Record a failed login attempt""" + now = datetime.now() + + if ip not in failed_attempts: + failed_attempts[ip] = {"count": 1, "locked_until": None} + else: + failed_attempts[ip]["count"] += 1 + + # Lock out after 5 failed attempts + if failed_attempts[ip]["count"] >= 5: + failed_attempts[ip]["locked_until"] = now + timedelta(minutes=5) + print(f"IP {ip} locked out until {failed_attempts[ip]['locked_until']}") + + +def clear_failed_attempts(ip: str): + """Clear failed attempts for an IP after successful login""" + if ip in failed_attempts: + del failed_attempts[ip] + + +def create_jwt_token(username: str) -> str: + """Create a JWT token for the user""" + expiration = datetime.utcnow() + timedelta(days=7) # Token valid for 7 days + payload = { + "username": username, + "exp": expiration + } + token = jwt.encode(payload, config_module.JWT_SECRET, algorithm="HS256") + return token + + +def verify_jwt_token(token: str) -> dict: + """Verify and decode a JWT token""" + try: + payload = jwt.decode(token, config_module.JWT_SECRET, algorithms=["HS256"]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Invalid token") + + +@router.post("/login") +async def login(request: Request, response: Response, req: LoginRequest): + """Login endpoint with rate limiting""" + client_ip = get_client_ip(request) + + # Check if IP is locked out + if is_locked_out(client_ip): + attempt_data = failed_attempts[client_ip] + locked_until = attempt_data["locked_until"] + remaining = (locked_until - datetime.now()).total_seconds() + raise HTTPException( + status_code=429, + detail=f"Too many failed attempts. Try again in {int(remaining)} seconds." + ) + + # Verify password (universal password for all users) + if req.password != config_module.AUTH_PASSWORD: + record_failed_attempt(client_ip) + attempts_left = 5 - failed_attempts[client_ip]["count"] + + if attempts_left <= 0: + raise HTTPException( + status_code=429, + detail="Too many failed attempts. Locked out for 5 minutes." + ) + + raise HTTPException( + status_code=401, + detail=f"Invalid password. {attempts_left} attempts remaining." + ) + + # Successful login + clear_failed_attempts(client_ip) + + # Create JWT token + token = create_jwt_token(req.name) + + # Set token as HTTP-only cookie + response.set_cookie( + key="auth_token", + value=token, + httponly=True, + max_age=7 * 24 * 60 * 60, # 7 days in seconds + samesite="lax" + ) + + print(f"User '{req.name}' logged in from IP {client_ip}") + + return {"status": "success", "username": req.name} + + +@router.post("/logout") +async def logout(response: Response): + """Logout endpoint""" + response.delete_cookie("auth_token") + return {"status": "success"} + + +@router.get("/status") +async def auth_status(request: Request): + """Check authentication status""" + token = request.cookies.get("auth_token") + + if not token: + return {"authenticated": False} + + try: + payload = verify_jwt_token(token) + return {"authenticated": True, "username": payload.get("username")} + except HTTPException: + return {"authenticated": False} diff --git a/routers/config.py b/routers/config.py index 579dc3a..91ac0d3 100644 --- a/routers/config.py +++ b/routers/config.py @@ -42,7 +42,9 @@ async def get_config(): "REASSIGN_DELAY": config_module.REASSIGN_DELAY, "ASSIGNMENT_TIMEOUT": config_module.ASSIGNMENT_TIMEOUT, "ASSIGNMENT_REMINDER": config_module.ASSIGNMENT_REMINDER, - "CHAIN_TIMER_THRESHOLD": config_module.CHAIN_TIMER_THRESHOLD + "CHAIN_TIMER_THRESHOLD": config_module.CHAIN_TIMER_THRESHOLD, + "AUTH_PASSWORD": config_module.AUTH_PASSWORD, + "JWT_SECRET": config_module.JWT_SECRET } if path.exists(): @@ -57,7 +59,7 @@ async def get_config(): # Mask sensitive values masked_config = config_values.copy() - sensitive = ["TORN_API_KEY", "FFSCOUTER_KEY", "DISCORD_TOKEN"] + sensitive = ["TORN_API_KEY", "FFSCOUTER_KEY", "DISCORD_TOKEN", "AUTH_PASSWORD", "JWT_SECRET"] for key in sensitive: if key in masked_config and masked_config[key]: val = str(masked_config[key]) @@ -75,7 +77,8 @@ async def update_config(req: ConfigUpdateRequest): valid_keys = { "TORN_API_KEY", "FFSCOUTER_KEY", "DISCORD_TOKEN", "ALLOWED_CHANNEL_ID", "HIT_CHECK_INTERVAL", "REASSIGN_DELAY", - "ASSIGNMENT_TIMEOUT", "ASSIGNMENT_REMINDER", "CHAIN_TIMER_THRESHOLD" + "ASSIGNMENT_TIMEOUT", "ASSIGNMENT_REMINDER", "CHAIN_TIMER_THRESHOLD", + "AUTH_PASSWORD", "JWT_SECRET" } # Validate key is valid @@ -98,7 +101,9 @@ async def update_config(req: ConfigUpdateRequest): "REASSIGN_DELAY": config_module.REASSIGN_DELAY, "ASSIGNMENT_TIMEOUT": config_module.ASSIGNMENT_TIMEOUT, "ASSIGNMENT_REMINDER": config_module.ASSIGNMENT_REMINDER, - "CHAIN_TIMER_THRESHOLD": config_module.CHAIN_TIMER_THRESHOLD + "CHAIN_TIMER_THRESHOLD": config_module.CHAIN_TIMER_THRESHOLD, + "AUTH_PASSWORD": config_module.AUTH_PASSWORD, + "JWT_SECRET": config_module.JWT_SECRET } } diff --git a/routers/pages.py b/routers/pages.py index c815286..15be19c 100644 --- a/routers/pages.py +++ b/routers/pages.py @@ -1,18 +1,34 @@ """Web page routes for dashboard and config page.""" from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates +from utils.auth import check_auth router = APIRouter() templates = Jinja2Templates(directory="templates") +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + """Login page""" + # If already authenticated, redirect to dashboard + if check_auth(request): + return RedirectResponse(url="/", status_code=302) + return templates.TemplateResponse("login.html", {"request": request}) + + @router.get("/", response_class=HTMLResponse) async def dashboard(request: Request): + """Dashboard page - requires authentication""" + if not check_auth(request): + return RedirectResponse(url="/login", status_code=302) print(">>> DASHBOARD ROUTE LOADED") return templates.TemplateResponse("dashboard.html", {"request": request}) @router.get("/config", response_class=HTMLResponse) async def config_page(request: Request): + """Config page - requires authentication""" + if not check_auth(request): + return RedirectResponse(url="/login", status_code=302) return templates.TemplateResponse("config.html", {"request": request}) diff --git a/static/config.js b/static/config.js index 2adf3ec..0aa99eb 100644 --- a/static/config.js +++ b/static/config.js @@ -8,7 +8,9 @@ const CONFIG_FIELDS = { "REASSIGN_DELAY": "reassign-delay", "ASSIGNMENT_TIMEOUT": "assignment-timeout", "ASSIGNMENT_REMINDER": "assignment-reminder", - "CHAIN_TIMER_THRESHOLD": "chain-timer-threshold" + "CHAIN_TIMER_THRESHOLD": "chain-timer-threshold", + "AUTH_PASSWORD": "auth-password", + "JWT_SECRET": "jwt-secret" }; let sensitiveFields = []; @@ -43,8 +45,11 @@ async function loadConfig() { } async function saveConfigValue(key) { + console.log("saveConfigValue called with key:", key); const inputId = CONFIG_FIELDS[key]; + console.log("Input ID:", inputId); const input = document.getElementById(inputId); + console.log("Input element:", input); if (!input) { console.error("Input not found:", inputId); @@ -52,13 +57,17 @@ async function saveConfigValue(key) { } let value = input.value.trim(); + console.log("Value to save:", value); // Don't save if sensitive field is empty (means user didn't change it) if (sensitiveFields.includes(key) && value === "") { + console.log("No changes to save - field is empty"); alert("No changes to save"); return; } + console.log("Proceeding with save..."); + // Convert to number if needed if (input.type === "number") { value = parseInt(value); @@ -69,11 +78,13 @@ async function saveConfigValue(key) { } try { + console.log("Sending API request to /api/config with:", { key, value }); const res = await fetch("/api/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value }) }); + console.log("API response status:", res.status); if (!res.ok) { console.error("Save failed:", res.status); @@ -94,12 +105,41 @@ async function saveConfigValue(key) { } function wireUp() { + console.log("wireUp called"); // Attach save handlers to all save buttons const saveButtons = document.querySelectorAll(".config-save-btn"); + console.log("Found save buttons:", saveButtons.length); saveButtons.forEach(btn => { const key = btn.dataset.key; + console.log("Attaching handler for key:", key); btn.addEventListener("click", () => saveConfigValue(key)); }); + + // Attach logout handler + const logoutBtn = document.getElementById("logout-btn"); + if (logoutBtn) logoutBtn.addEventListener("click", handleLogout); +} + +async function handleLogout() { + console.log("handleLogout called"); + try { + console.log("Sending logout request to /auth/logout"); + const response = await fetch("/auth/logout", { + method: "POST" + }); + console.log("Logout response status:", response.status); + + if (response.ok) { + console.log("Logout successful, redirecting to /login"); + window.location.href = "/login"; + } else { + console.error("Logout failed with status:", response.status); + window.location.href = "/login"; + } + } catch (error) { + console.error("Error during logout:", error); + window.location.href = "/login"; + } } document.addEventListener("DOMContentLoaded", async () => { diff --git a/static/dashboard.js b/static/dashboard.js index bf08a01..77c1a3b 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -730,10 +730,41 @@ function wireUp() { const resetBtn = document.getElementById("reset-groups-btn"); if (resetBtn) resetBtn.addEventListener("click", resetGroups); + const logoutBtn = document.getElementById("logout-btn"); + if (logoutBtn) logoutBtn.addEventListener("click", handleLogout); + setupDropZones(); console.log(">>> wireUp completed"); } +// --------------------------- +// Logout handler +// --------------------------- +async function handleLogout() { + console.log("handleLogout called"); + try { + console.log("Sending logout request to /auth/logout"); + const response = await fetch("/auth/logout", { + method: "POST" + }); + console.log("Logout response status:", response.status); + + if (response.ok) { + console.log("Logout successful, redirecting to /login"); + // Redirect to login page + window.location.href = "/login"; + } else { + console.error("Logout failed with status:", response.status); + // Still redirect to login page + window.location.href = "/login"; + } + } catch (error) { + console.error("Error during logout:", error); + // Still redirect to login page + window.location.href = "/login"; + } +} + // --------------------------- // Initial load // --------------------------- diff --git a/templates/config.html b/templates/config.html index 0066313..c14ebd4 100644 --- a/templates/config.html +++ b/templates/config.html @@ -12,6 +12,7 @@

Configuration

@@ -95,6 +96,24 @@ + + +
+

Authentication Settings

+
+ +

Shared password that all users use to log in

+ + +
+ +
+ +

Secret key for session tokens (change from default for security)

+ + +
+
diff --git a/templates/dashboard.html b/templates/dashboard.html index 6cb65db..351119d 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -14,6 +14,7 @@
Settings +
diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..2eb6fe3 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,214 @@ + + + + + Login - War Dashboard + + + + + + + + + diff --git a/utils/__pycache__/auth.cpython-311.pyc b/utils/__pycache__/auth.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..748cd340049bda9f3cf669c231ef5eb7cbef6d83 GIT binary patch literal 1958 zcmbVMO=ufO6rRGfS0)Z2G%Xaj;95%U&?Y5PC=r5Xvon%cmUq?J zQEUqbOzFV~A6h8Hmx3>G6B1~TrKk8%$e{w|?5mWYUhL@r4<^o>+dy7CanGRR>Z$f2~-n4YV%C1Nqt(3xplYMq%?lbOU(bqlMQ z)QE+NWq{)1-#mot>3}QWBwy8u!`#?HKL15_%^)?t)|F{jAEJcSsbvUW&^`oV65%R3tl;s-*TN8Da!*32(n}rM z5SW)>|63*0v%$f#@FcVcu|4MTdE;JS1JW~rSl2TDGalmR6ibEYDr3WUXdW zf*Y|#>#klkF$4wb^pGnXw!I9s;)-R$C{MjBR#q8z>N$@YwpB9A+KP?qRpN%PeVNyC z+4-Bb8DzhB9q)rRv1Lj<o@ytOy(~4)BO2#u)2aWvqQ%pM|_ji1J>_j2_pC;y# zXg@iJK1qtFv-Nkd`rm8~@FoL%9N=uF&oSU*pA*2Yz6}bmIA;uXo$BFkD||}P~+;n#~-uQB?`2ID_s#IdHBN_EpA zQ2O0&m!>@Wt26>gc`oy5g~qus%wdGXD2D`xB!@E`QUK|ISHkoxygR&gsVaRFCO7O+ zTjTN2F;Kk04ORhOQ)g|z&Qk5+Gh6kJBn4B4N@P3y?Uf&9T5nx`D*v+HR2C1E#g?)N zFYV#v7CSzS4!5H3JaAf5v(3myyiigv!E*$zph{A_NcahM$LA!pFOAJji2LILU^?Q; znucvd)4X%Ka!H340zH9Wu21`r&vftfu7pj4dBkxgo>cE_kI)fVBk$lZlv%$ed`u@m z$9ulB4FGWnLK|IbqDyTQ-t>NLG~GPy-$rjYd;d--CS--3atHDGA+U(gr=S`ZQaf@7 P!L*z4rq==;hk*VCn?T!| literal 0 HcmV?d00001 diff --git a/utils/auth.py b/utils/auth.py new file mode 100644 index 0000000..29b7aaf --- /dev/null +++ b/utils/auth.py @@ -0,0 +1,33 @@ +"""Authentication utilities and dependencies.""" +import jwt +from fastapi import Request, HTTPException +import config as config_module + + +def get_current_user(request: Request) -> dict: + """Dependency to check authentication and return user info""" + token = request.cookies.get("auth_token") + + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + payload = jwt.decode(token, config_module.JWT_SECRET, algorithms=["HS256"]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Invalid token") + + +def check_auth(request: Request) -> bool: + """Check if user is authenticated (returns bool, doesn't raise exception)""" + token = request.cookies.get("auth_token") + if not token: + return False + + try: + jwt.decode(token, config_module.JWT_SECRET, algorithms=["HS256"]) + return True + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return False