Jack-ki1's picture
Update auth.py
24ccc3e verified
Raw
History Blame Contribute Delete
5.64 kB
"""
auth.py
-------
A deliberately minimal auth layer: a single shared admin password, checked
via a Flask session flag. No user accounts, no database table -- this is not
meant to replace real auth for a multi-admin institution, just to stop a
public Hugging Face Space URL from being a wide-open door to student data.
If config.ADMIN_PASSWORD is empty (the default), `login_required` is a no-op
and every route behaves exactly as if this file didn't exist -- matching the
original "no login system needed" brief for local/demo use. Set ADMIN_PASSWORD
as an environment variable (an HF Space "secret") to turn the gate on.
This file also carries three small pieces of security hardening added during
a later audit pass, kept here rather than scattered across app.py:
1. `check_password` -- constant-time password comparison. A plain `==`
comparison on strings returns as soon as it finds a mismatched
character, which leaks (via response timing) how many leading
characters of a guess were correct. Not a huge risk for a low-traffic
app, but it costs nothing to close.
2. `RateLimiter` -- a small in-memory sliding-window limiter. Used to
slow down login brute-forcing and to stop `/attendance` from being
hammered into running expensive CNN inference in a loop. It's
per-process/in-memory by design, which matches this app's single
gunicorn worker (see Dockerfile) -- it will NOT coordinate across
multiple workers/replicas. Documented as a known limitation in the
README; a shared store (Redis) would be the real fix at that scale.
3. CSRF token helpers -- a minimal, dependency-free CSRF protection
(session-bound random token, checked on every POST) rather than
pulling in Flask-WTF for a handful of forms.
"""
import hmac
import secrets
import time
from collections import defaultdict
from functools import wraps
from flask import session, redirect, url_for, request, abort
import config
# ---------------------------------------------------------------------------
# Login gate
# ---------------------------------------------------------------------------
def is_logged_in():
return (not config.AUTH_ENABLED) or session.get("logged_in", False)
def login_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if not is_logged_in():
return redirect(url_for("login", next=request.path))
return view_func(*args, **kwargs)
return wrapped
def check_password(submitted):
"""Constant-time comparison against the configured admin password."""
if not config.ADMIN_PASSWORD:
return False
return hmac.compare_digest(submitted, config.ADMIN_PASSWORD)
# ---------------------------------------------------------------------------
# Rate limiting (in-memory, per-process -- see module docstring)
# ---------------------------------------------------------------------------
class RateLimiter:
def __init__(self, max_attempts, window_seconds):
self.max_attempts = max_attempts
self.window_seconds = window_seconds
self._attempts = defaultdict(list)
def _prune(self, key, now):
cutoff = now - self.window_seconds
self._attempts[key] = [t for t in self._attempts[key] if t > cutoff]
def is_limited(self, key):
now = time.time()
self._prune(key, now)
return len(self._attempts[key]) >= self.max_attempts
def record(self, key):
self._attempts[key].append(time.time())
def reset(self, key):
self._attempts.pop(key, None)
login_limiter = RateLimiter(max_attempts=5, window_seconds=300) # 5 tries / 5 min
attendance_limiter = RateLimiter(max_attempts=20, window_seconds=60) # 20 tries / min
def client_ip():
"""
request.remote_addr, trusting X-Forwarded-For as set up by ProxyFix in
app.py. Falls back to 'unknown' rather than crashing if called outside
a request context.
"""
try:
return request.remote_addr or "unknown"
except RuntimeError:
return "unknown"
# ---------------------------------------------------------------------------
# CSRF protection (session-bound token, no external dependency)
# ---------------------------------------------------------------------------
def get_csrf_token():
"""Generate or retrieve the CSRF token for the current session."""
if "csrf_token" not in session:
session["csrf_token"] = secrets.token_hex(32)
session.modified = True
return session["csrf_token"]
def csrf_valid(submitted_token):
session_token = session.get("csrf_token")
if not session_token or not submitted_token:
return False
return hmac.compare_digest(session_token, submitted_token)
def csrf_protect():
"""
Call from an app.before_request hook. Aborts 400 on a bad/missing token.
Checks for CSRF token in:
1. Form data (request.form.get("csrf_token"))
2. JSON body (request.get_json(silent=True) or {})
3. X-CSRF-Token header
"""
if request.method == "POST":
# Try form data first
token = request.form.get("csrf_token", "")
# If not in form, try JSON body
if not token and request.is_json:
json_data = request.get_json(silent=True) or {}
token = json_data.get("csrf_token", "")
# If still not found, try header
if not token:
token = request.headers.get("X-CSRF-Token", "")
if not csrf_valid(token):
abort(400, description="Your session expired or the form was submitted from an untrusted source — please reload the page and try again.")