Files
boker/routes/account.py
T

542 lines
19 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from urllib.parse import urlsplit
from flask import Blueprint, flash, redirect, render_template, request, session as flask_session, url_for
from auth import (
current_user_id,
generate_invite_token,
generate_reset_token,
hash_password,
log_user_in,
log_user_out,
login_required,
normalize_email,
verify_invite_token,
verify_password,
verify_reset_token,
)
from db import database_extensions_available, db
from extensions import limiter
account_bp = Blueprint("account", __name__, url_prefix="/account")
EMAIL_VERIFICATION_TTL = timedelta(minutes=15)
def db_ready() -> bool:
return database_extensions_available() and db is not None
def safe_next_url(default: str) -> str:
next_url = request.args.get("next", "").strip()
if not next_url:
return default
parsed = urlsplit(next_url)
if parsed.scheme or parsed.netloc:
return default
if not next_url.startswith("/"):
return default
return next_url
def _verification_next(default: str) -> str:
next_url = flask_session.get("pending_verification_next")
if isinstance(next_url, str) and next_url.startswith("/") and not urlsplit(next_url).netloc:
return next_url
return default
def _verification_sent_at_valid(sent_at) -> bool:
if sent_at is None:
return False
if sent_at.tzinfo is None:
sent_at = sent_at.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) - sent_at <= EMAIL_VERIFICATION_TTL
def _new_verification_code() -> str:
return f"{secrets.randbelow(1000000):06d}"
def _issue_verification_code(user) -> str:
from db_models import utc_now
code = _new_verification_code()
user.email_verification_code_hash = hash_password(code)
user.email_verification_sent_at = utc_now()
return code
def _send_verification_code(user) -> None:
from emails import send_email_verification_code
code = _issue_verification_code(user)
db.session.commit()
send_email_verification_code(user.email, code)
def _start_email_verification(user, next_url: str) -> str:
flask_session["pending_verification_user_id"] = user.id
flask_session["pending_verification_next"] = next_url
try:
_send_verification_code(user)
flash("Check your email for a verification code.", "success")
except Exception:
flash("Account created, but we could not send a verification code. Try resending it.", "error")
return url_for("account.verify_email")
@account_bp.get("/")
@login_required
def home():
return redirect(url_for("leagues.index"))
@account_bp.route("/register", methods=["GET", "POST"])
@limiter.limit("10 per minute")
def register():
if current_user_id():
return redirect(safe_next_url(url_for("leagues.index")))
if not db_ready():
flash("Account database is not available.", "error")
return render_template("account_register.html", form={})
form = {
"email": (request.form.get("email") or request.args.get("email") or "").strip(),
}
if request.method == "POST":
from league_repositories import create_user, find_user_by_email
email = normalize_email(form["email"])
password = request.form.get("password", "")
confirm_password = request.form.get("confirm_password", "")
existing_user = find_user_by_email(email)
if not email or "@" not in email:
flash("Enter a valid email address.", "error")
elif len(password) < 8:
flash("Password must be at least 8 characters.", "error")
elif password != confirm_password:
flash("Passwords do not match.", "error")
elif existing_user and existing_user.disabled_at is None:
flash("An account already exists for that email.", "error")
else:
if existing_user:
user = existing_user
user.password_hash = hash_password(password)
user.disabled_at = None
user.email_verified_at = None
user.email_verification_code_hash = None
user.email_verification_sent_at = None
else:
user = create_user(email, password)
db.session.flush()
return redirect(_start_email_verification(user, safe_next_url(url_for("leagues.new"))))
return render_template("account_register.html", form=form)
@account_bp.route("/login", methods=["GET", "POST"])
@limiter.limit("20 per minute")
def login():
if current_user_id():
return redirect(safe_next_url(url_for("leagues.index")))
if not db_ready():
flash("Account database is not available.", "error")
return render_template("account_login.html", form={})
form = {
"email": request.form.get("email", "").strip(),
}
if request.method == "POST":
from league_repositories import find_user_by_email
user = find_user_by_email(form["email"])
password = request.form.get("password", "")
if user is None or not verify_password(user.password_hash, password):
flash("Invalid email or password.", "error")
elif user.disabled_at is not None:
flash("That account is disabled.", "error")
elif user.email_verified_at is None:
flask_session["pending_verification_user_id"] = user.id
flask_session["pending_verification_next"] = safe_next_url(url_for("leagues.index"))
try:
_send_verification_code(user)
flash("Verify your email to continue. We sent you a new code.", "success")
except Exception:
flash("Verify your email to continue. We could not send a new code.", "error")
return redirect(url_for("account.verify_email"))
else:
log_user_in(user.id)
next_url = safe_next_url(url_for("leagues.index"))
flash("Logged in.", "success")
return redirect(next_url)
return render_template("account_login.html", form=form)
@account_bp.route("/verify-email", methods=["GET", "POST"])
@limiter.limit("10 per minute")
def verify_email():
if current_user_id():
return redirect(safe_next_url(url_for("leagues.index")))
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.login"))
from db_models import User, utc_now
user_id = flask_session.get("pending_verification_user_id")
user = db.session.get(User, user_id) if user_id else None
if user is None or user.disabled_at is not None:
flask_session.pop("pending_verification_user_id", None)
flask_session.pop("pending_verification_next", None)
flash("Start again to verify your email.", "error")
return redirect(url_for("account.register"))
if user.email_verified_at is not None:
log_user_in(user.id)
next_url = _verification_next(url_for("leagues.index"))
flask_session.pop("pending_verification_user_id", None)
flask_session.pop("pending_verification_next", None)
return redirect(next_url)
if request.method == "POST":
code = "".join(ch for ch in request.form.get("code", "") if ch.isdigit())
if len(code) != 6:
flash("Enter the six-digit verification code.", "error")
elif not _verification_sent_at_valid(user.email_verification_sent_at):
flash("That code has expired. Request a new one.", "error")
elif not user.email_verification_code_hash or not verify_password(user.email_verification_code_hash, code):
flash("That verification code is not correct.", "error")
else:
user.email_verified_at = utc_now()
user.email_verification_code_hash = None
user.email_verification_sent_at = None
db.session.commit()
log_user_in(user.id)
next_url = _verification_next(url_for("leagues.index"))
flask_session.pop("pending_verification_user_id", None)
flask_session.pop("pending_verification_next", None)
flash("Email verified.", "success")
return redirect(next_url)
return render_template("account_verify_email.html", email=user.email)
@account_bp.post("/verify-email/resend")
@limiter.limit("3 per minute")
def resend_verification_code():
if current_user_id():
return redirect(url_for("leagues.index"))
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.login"))
from db_models import User
user_id = flask_session.get("pending_verification_user_id")
user = db.session.get(User, user_id) if user_id else None
if user is None or user.disabled_at is not None:
flash("Start again to verify your email.", "error")
return redirect(url_for("account.register"))
try:
_send_verification_code(user)
flash("A new verification code has been sent.", "success")
except Exception:
flash("We could not send a new code. Check your mail configuration.", "error")
return redirect(url_for("account.verify_email"))
@account_bp.post("/logout")
def logout():
log_user_out()
flash("Logged out.", "success")
return redirect(url_for("public.home"))
@account_bp.get("/settings")
@login_required
def settings():
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("leagues.index"))
from db_models import User
user = db.session.get(User, current_user_id())
if user is None:
flash("User not found.", "error")
return redirect(url_for("public.home"))
return render_template("account_settings.html", user=user)
@account_bp.post("/settings/email")
@login_required
def update_email():
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.settings"))
from db_models import User
user = db.session.get(User, current_user_id())
if user is None:
flash("User not found.", "error")
return redirect(url_for("account.settings"))
new_email = normalize_email(request.form.get("email", ""))
current_password = request.form.get("current_password", "")
if not new_email or "@" not in new_email:
flash("Enter a valid email address.", "error")
elif not verify_password(user.password_hash, current_password):
flash("Current password is incorrect.", "error")
else:
from league_repositories import find_user_by_email
existing = find_user_by_email(new_email)
if existing and existing.id != user.id:
flash("That email is already in use.", "error")
else:
user.email = new_email
db.session.commit()
flash("Email updated.", "success")
return redirect(url_for("account.settings"))
@account_bp.post("/settings/password")
@login_required
def update_password():
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.settings"))
from db_models import User
user = db.session.get(User, current_user_id())
if user is None:
flash("User not found.", "error")
return redirect(url_for("account.settings"))
current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
if not verify_password(user.password_hash, current_password):
flash("Current password is incorrect.", "error")
elif len(new_password) < 8:
flash("New password must be at least 8 characters.", "error")
elif new_password != confirm_password:
flash("Passwords do not match.", "error")
else:
user.password_hash = hash_password(new_password)
db.session.commit()
flash("Password changed.", "success")
return redirect(url_for("account.settings"))
@account_bp.post("/disable")
@login_required
def disable_account():
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.settings"))
from db_models import League, User, utc_now
user = db.session.get(User, current_user_id())
if user is None:
flash("User not found.", "error")
return redirect(url_for("account.settings"))
confirm = request.form.get("confirm", "").strip()
current_password = request.form.get("current_password", "")
if confirm != "DISABLE":
flash("Confirmation text did not match.", "error")
return redirect(url_for("account.settings"))
if not verify_password(user.password_hash, current_password):
flash("Password is incorrect.", "error")
return redirect(url_for("account.settings"))
owned_leagues = League.query.filter_by(created_by_user_id=user.id, archived_at=None).all()
for league in owned_leagues:
league.archived_at = utc_now()
user.disabled_at = utc_now()
db.session.commit()
log_user_out()
flash("Your account has been disabled.", "success")
return redirect(url_for("public.home"))
@account_bp.post("/delete")
@login_required
def delete_account():
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.settings"))
from db_models import League, LeagueMembership, User, utc_now
from league_repositories import delete_league
user = db.session.get(User, current_user_id())
if user is None:
flash("User not found.", "error")
return redirect(url_for("account.settings"))
confirm = request.form.get("confirm", "").strip()
current_password = request.form.get("current_password", "")
if confirm != "DELETE":
flash("Confirmation text did not match.", "error")
return redirect(url_for("account.settings"))
if not verify_password(user.password_hash, current_password):
flash("Password is incorrect.", "error")
return redirect(url_for("account.settings"))
for league in League.query.filter_by(created_by_user_id=user.id).all():
delete_league(league)
LeagueMembership.query.filter_by(user_id=user.id).delete(synchronize_session=False)
db.session.delete(user)
db.session.commit()
log_user_out()
flash("Your account and all data have been permanently deleted.", "success")
return redirect(url_for("public.home"))
@account_bp.route("/forgot-password", methods=["GET", "POST"])
@limiter.limit("10 per minute")
def forgot_password():
if current_user_id():
return redirect(url_for("leagues.index"))
if request.method == "POST":
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.forgot_password"))
from emails import send_password_reset
from flask import current_app
from league_repositories import find_user_by_email
email = normalize_email(request.form.get("email", ""))
user = find_user_by_email(email)
if user and user.disabled_at is None:
token = generate_reset_token(user.id)
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
reset_url = f"{base_url}{url_for('account.reset_password', token=token)}"
try:
send_password_reset(email, reset_url)
except Exception:
pass
flash("If that email is registered, a reset link has been sent.", "success")
return redirect(url_for("account.login"))
return render_template("forgot_password.html")
@account_bp.route("/reset-password/<token>", methods=["GET", "POST"])
def reset_password(token):
if current_user_id():
return redirect(url_for("leagues.index"))
user_id = verify_reset_token(token)
if user_id is None:
flash("That reset link is invalid or has expired.", "error")
return redirect(url_for("account.forgot_password"))
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.login"))
from db_models import User
user = db.session.get(User, user_id)
if user is None or user.disabled_at is not None:
flash("That reset link is invalid or has expired.", "error")
return redirect(url_for("account.forgot_password"))
if request.method == "POST":
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
if len(new_password) < 8:
flash("Password must be at least 8 characters.", "error")
elif new_password != confirm_password:
flash("Passwords do not match.", "error")
else:
user.password_hash = hash_password(new_password)
db.session.commit()
flash("Password reset. You can now log in.", "success")
return redirect(url_for("account.login"))
return render_template("reset_password.html", token=token)
@account_bp.get("/accept-invite/<token>")
def accept_invite(token):
data = verify_invite_token(token)
if data is None:
flash("That invitation link is invalid or has expired.", "error")
return redirect(url_for("public.home"))
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("leagues.index"))
from db_models import User
from league_repositories import add_league_member, find_league_by_id, find_membership, find_user_by_email
invite_email = normalize_email(data.get("email", ""))
invite_next = url_for("account.accept_invite", token=token)
if not current_user_id():
if find_user_by_email(invite_email):
flash("Sign in to accept your invitation.", "info")
return redirect(url_for("account.login", next=invite_next))
flash("Create an account to accept your invitation.", "info")
return redirect(url_for("account.register", next=invite_next, email=invite_email))
user = db.session.get(User, current_user_id())
if user is None or user.email != invite_email:
flash("This invitation was sent to a different email address.", "error")
return redirect(url_for("leagues.index"))
league = find_league_by_id(data["league_id"])
if league is None:
flash("That league no longer exists.", "error")
return redirect(url_for("leagues.index"))
existing = find_membership(league.id, user.id)
if existing:
flash(f"You are already a member of {league.name}.", "info")
return redirect(url_for("leagues.dashboard", league_ref=league.url_ref))
add_league_member(league.id, user.id, data["role"], data.get("invited_by", ""))
db.session.commit()
flash(f"Welcome to {league.name}! You joined as {data['role']}.", "success")
return redirect(url_for("leagues.dashboard", league_ref=league.url_ref))