193 lines
6.4 KiB
Python
193 lines
6.4 KiB
Python
"""Login (password + emailed one-time code), sessions and CSRF protection."""
|
|
|
|
import functools
|
|
import hmac
|
|
import secrets
|
|
import time
|
|
|
|
from flask import (Blueprint, abort, current_app, flash, g, redirect, render_template, request,
|
|
session, url_for)
|
|
|
|
from . import db, mailer, security
|
|
|
|
bp = Blueprint("auth", __name__)
|
|
|
|
CODE_TTL = 600
|
|
CODE_ATTEMPTS = 5
|
|
FAIL_WINDOW = 900
|
|
FAIL_LIMIT = 10
|
|
|
|
|
|
def conf():
|
|
return current_app.config["PB_CONF"]
|
|
|
|
|
|
def lookup_user(username):
|
|
"""Returns dict(username, email, pwhash, sysop) or None."""
|
|
c = conf()
|
|
if username == c.get("sysopuser"):
|
|
return {"username": username, "email": c.get("sysopemail"), "pwhash": c.get("sysoppassword"),
|
|
"sysop": True}
|
|
row = db.get().execute("SELECT username, email, pwhash FROM users WHERE username = ?", (username,)).fetchone()
|
|
if row:
|
|
return {"username": row["username"], "email": row["email"], "pwhash": row["pwhash"], "sysop": False}
|
|
return None
|
|
|
|
|
|
def _too_many_failures(addr):
|
|
cutoff = db.now() - FAIL_WINDOW
|
|
conn = db.get()
|
|
conn.execute("DELETE FROM login_failures WHERE ts < ?", (cutoff,))
|
|
n = conn.execute("SELECT COUNT(*) FROM login_failures WHERE addr = ?", (addr,)).fetchone()[0]
|
|
return n >= FAIL_LIMIT
|
|
|
|
|
|
def _record_failure(addr):
|
|
db.get().execute("INSERT INTO login_failures (addr, ts) VALUES (?, ?)", (addr, db.now()))
|
|
|
|
|
|
def csrf_token():
|
|
if "csrf" not in session:
|
|
session["csrf"] = secrets.token_urlsafe(32)
|
|
return session["csrf"]
|
|
|
|
|
|
@bp.before_app_request
|
|
def check_csrf():
|
|
if request.method in ("GET", "HEAD", "OPTIONS"):
|
|
return
|
|
sent = request.headers.get("X-CSRF-Token") or request.form.get("csrf_token", "")
|
|
if not sent or not hmac.compare_digest(sent, session.get("csrf", "")):
|
|
abort(400, "invalid CSRF token")
|
|
|
|
|
|
@bp.before_app_request
|
|
def load_user():
|
|
g.user = None
|
|
name = session.get("user")
|
|
if not name:
|
|
return
|
|
if time.time() - session.get("login_at", 0) > conf().getint("sessionhours", 12) * 3600:
|
|
session.clear()
|
|
return
|
|
user = lookup_user(name)
|
|
# Password changes invalidate other sessions of that user.
|
|
if user and session.get("pwtag") == user["pwhash"][-16:]:
|
|
g.user = user
|
|
else:
|
|
session.clear()
|
|
|
|
|
|
def login_required(view):
|
|
@functools.wraps(view)
|
|
def wrapped(*a, **kw):
|
|
if g.user is None:
|
|
if request.path.startswith("/api/"):
|
|
abort(401)
|
|
return redirect(url_for("auth.login"))
|
|
return view(*a, **kw)
|
|
return wrapped
|
|
|
|
|
|
def sysop_required(view):
|
|
@functools.wraps(view)
|
|
@login_required
|
|
def wrapped(*a, **kw):
|
|
if not g.user["sysop"]:
|
|
abort(403)
|
|
return view(*a, **kw)
|
|
return wrapped
|
|
|
|
|
|
@bp.route("/login", methods=["GET", "POST"])
|
|
def login():
|
|
if g.user:
|
|
return redirect(url_for("views.patch"))
|
|
if request.method == "GET":
|
|
return render_template("login.html")
|
|
|
|
addr = request.remote_addr or "?"
|
|
if _too_many_failures(addr):
|
|
flash("Too many failed attempts. Try again later.", "error")
|
|
return render_template("login.html"), 429
|
|
|
|
username = request.form.get("username", "").strip()
|
|
password = request.form.get("password", "")
|
|
user = lookup_user(username)
|
|
if not security.verify_password(password, user["pwhash"] if user else None) or not user:
|
|
_record_failure(addr)
|
|
flash("Invalid username or password.", "error")
|
|
return render_template("login.html"), 401
|
|
if not user["email"]:
|
|
flash("This account has no email address for the login code.", "error")
|
|
return render_template("login.html"), 400
|
|
|
|
tok, code = security.token(), security.new_code()
|
|
conn = db.get()
|
|
conn.execute("DELETE FROM login_codes WHERE expires < ? OR username = ?", (db.now(), username))
|
|
conn.execute("INSERT INTO login_codes (token, username, codehash, expires) VALUES (?, ?, ?, ?)",
|
|
(tok, username, security.code_hash(tok, code), db.now() + CODE_TTL))
|
|
try:
|
|
mailer.send(conf(), user["email"], "PatchBay login code",
|
|
f"Your PatchBay login code is: {code}\n\n"
|
|
f"It is valid for {CODE_TTL // 60} minutes. If you did not try to log in, "
|
|
f"someone knows your password; change it.\n")
|
|
except mailer.MailError as e:
|
|
current_app.logger.error("sending login code failed: %s", e)
|
|
flash("Could not send the login code. Check the mail settings.", "error")
|
|
return render_template("login.html"), 503
|
|
|
|
session.clear()
|
|
session["pending"] = tok
|
|
return redirect(url_for("auth.verify"))
|
|
|
|
|
|
@bp.route("/verify", methods=["GET", "POST"])
|
|
def verify():
|
|
tok = session.get("pending")
|
|
if not tok:
|
|
return redirect(url_for("auth.login"))
|
|
if request.method == "GET":
|
|
return render_template("verify.html")
|
|
|
|
conn = db.get()
|
|
row = conn.execute("SELECT username, codehash, expires, attempts FROM login_codes WHERE token = ?",
|
|
(tok,)).fetchone()
|
|
if not row or row["expires"] < db.now() or row["attempts"] >= CODE_ATTEMPTS:
|
|
conn.execute("DELETE FROM login_codes WHERE token = ?", (tok,))
|
|
session.clear()
|
|
flash("The login code expired. Please log in again.", "error")
|
|
return redirect(url_for("auth.login"))
|
|
|
|
code = request.form.get("code", "").strip()
|
|
if not hmac.compare_digest(security.code_hash(tok, code), row["codehash"]):
|
|
conn.execute("UPDATE login_codes SET attempts = attempts + 1 WHERE token = ?", (tok,))
|
|
_record_failure(request.remote_addr or "?")
|
|
flash("Wrong code.", "error")
|
|
return render_template("verify.html"), 401
|
|
|
|
conn.execute("DELETE FROM login_codes WHERE token = ?", (tok,))
|
|
user = lookup_user(row["username"])
|
|
if not user:
|
|
session.clear()
|
|
return redirect(url_for("auth.login"))
|
|
session.clear()
|
|
session.permanent = True
|
|
session["user"] = user["username"]
|
|
session["login_at"] = time.time()
|
|
session["pwtag"] = user["pwhash"][-16:]
|
|
csrf_token()
|
|
return redirect(url_for("views.patch"))
|
|
|
|
|
|
@bp.route("/logout", methods=["POST"])
|
|
def logout():
|
|
session.clear()
|
|
return redirect(url_for("auth.login"))
|
|
|
|
|
|
def refresh_pwtag():
|
|
"""Keeps the current session valid after the user changed their own password."""
|
|
user = lookup_user(session["user"])
|
|
session["pwtag"] = user["pwhash"][-16:]
|