diff --git a/config.py b/config.py index 3752cf5..77448bb 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" ELIGIBLE_MIN_SESSIONS = 3 -APP_VERSION = "2.5.13" +APP_VERSION = "2.5.14" def load_local_env(env_path: Path) -> None: diff --git a/db_models.py b/db_models.py index 525c0e4..c360e8c 100644 --- a/db_models.py +++ b/db_models.py @@ -73,6 +73,8 @@ class User(TimestampMixin, db.Model): email = db.Column(db.String(255), nullable=False, unique=True, index=True) password_hash = db.Column(db.String(255), nullable=False) email_verified_at = db.Column(db.DateTime(timezone=True), nullable=True) + email_verification_code_hash = db.Column(db.String(255), nullable=True) + email_verification_sent_at = db.Column(db.DateTime(timezone=True), nullable=True) last_login_at = db.Column(db.DateTime(timezone=True), nullable=True) disabled_at = db.Column(db.DateTime(timezone=True), nullable=True) diff --git a/emails.py b/emails.py index b8de782..1830f4c 100644 --- a/emails.py +++ b/emails.py @@ -41,6 +41,20 @@ def send_password_reset(to_email: str, reset_url: str) -> None: _send_message(msg) +def send_email_verification_code(to_email: str, code: str) -> None: + msg = Message( + subject="Your myboker.org verification code", + recipients=[to_email], + body=( + "Verify your myboker.org account with this code:\n\n" + f"{code}\n\n" + "This code expires in 15 minutes. If you did not create an account, you can ignore this email." + ), + sender=current_app.config.get("MAIL_DEFAULT_SENDER"), + ) + _send_message(msg) + + def send_league_invite(to_email: str, league_name: str, invite_url: str, invited_by_email: str, role: str) -> None: role_label = role.strip().lower() if role else "member" msg = Message( diff --git a/migrations/versions/0004_email_verification_codes.py b/migrations/versions/0004_email_verification_codes.py new file mode 100644 index 0000000..f6ac9ac --- /dev/null +++ b/migrations/versions/0004_email_verification_codes.py @@ -0,0 +1,28 @@ +"""add email verification code fields + +Revision ID: 0004_email_verification_codes +Revises: 0003_league_settings +Create Date: 2026-06-27 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0004_email_verification_codes" +down_revision = "0003_league_settings" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("users") as batch_op: + batch_op.add_column(sa.Column("email_verification_code_hash", sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column("email_verification_sent_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("users") as batch_op: + batch_op.drop_column("email_verification_sent_at") + batch_op.drop_column("email_verification_code_hash") diff --git a/routes/account.py b/routes/account.py index b3eacea..16d951a 100644 --- a/routes/account.py +++ b/routes/account.py @@ -1,9 +1,11 @@ #!/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, url_for +from flask import Blueprint, flash, redirect, render_template, request, session as flask_session, url_for from auth import ( current_user_id, @@ -22,6 +24,7 @@ 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: @@ -41,6 +44,53 @@ def safe_next_url(default: str) -> str: 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(): @@ -78,10 +128,8 @@ def register(): flash("An account already exists for that email.", "error") else: user = create_user(email, password) - db.session.commit() - log_user_in(user.id) - flash("Account created.", "success") - return redirect(safe_next_url(url_for("leagues.new"))) + db.session.flush() + return redirect(_start_email_verification(user, safe_next_url(url_for("leagues.new")))) return render_template("account_register.html", form=form) @@ -110,6 +158,15 @@ def login(): 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")) @@ -119,6 +176,82 @@ def login(): 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() diff --git a/static/css/style.css b/static/css/style.css index f7a189a..f548e68 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -5467,6 +5467,34 @@ select.control { cursor: pointer; } color: var(--faintest-2); } +.auth-field--code { + margin-bottom: 18px; +} + +.auth-code-input { + text-align: center; + font-family: var(--font-mono) !important; + font-size: 30px !important; + font-weight: 800 !important; + letter-spacing: .38em; +} + +.auth-resend-form { + margin-top: 16px; + text-align: center; +} + +.auth-link-button { + all: unset; + cursor: pointer; + color: var(--muted); + font: 600 13px/1.2 var(--font-ui); +} +.auth-link-button:hover { + color: var(--accent); + text-decoration: underline; +} + .auth-links { margin: -8px 0 16px; text-align: right; diff --git a/templates/account_verify_email.html b/templates/account_verify_email.html new file mode 100644 index 0000000..be8cce3 --- /dev/null +++ b/templates/account_verify_email.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}Verify Email · myboker.org{% endblock %} +{% block page_class %}page--auth{% endblock %} +{% block content %} + +
+
+
+ +
+ myboker.org +
+ +

Verify your email.

+

Enter the six-digit code sent to {{ email }}.

+ +
+ +
+ + +
+ +
+ +
+ + +
+
+
+ + +
+ +{% endblock %}