update email verification process

This commit is contained in:
SowinskiBraeden committed 2026-06-27 12:35:00 -07:00
1 parent 1b442011be
commit ffc33c967a
7 files changed
+257 -6

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv"
DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}"
ELIGIBLE_MIN_SESSIONS = 3 ELIGIBLE_MIN_SESSIONS = 3
APP_VERSION = "2.5.13" APP_VERSION = "2.5.14"
def load_local_env(env_path: Path) -> None: def load_local_env(env_path: Path) -> None:
+2
View File
@@ -73,6 +73,8 @@ class User(TimestampMixin, db.Model):
email = db.Column(db.String(255), nullable=False, unique=True, index=True) email = db.Column(db.String(255), nullable=False, unique=True, index=True)
password_hash = db.Column(db.String(255), nullable=False) password_hash = db.Column(db.String(255), nullable=False)
email_verified_at = db.Column(db.DateTime(timezone=True), nullable=True) 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) last_login_at = db.Column(db.DateTime(timezone=True), nullable=True)
disabled_at = db.Column(db.DateTime(timezone=True), nullable=True) disabled_at = db.Column(db.DateTime(timezone=True), nullable=True)
+14
View File
@@ -41,6 +41,20 @@ def send_password_reset(to_email: str, reset_url: str) -> None:
_send_message(msg) _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: 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" role_label = role.strip().lower() if role else "member"
msg = Message( msg = Message(
@@ -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")
+138 -5
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from urllib.parse import urlsplit 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 ( from auth import (
current_user_id, current_user_id,
@@ -22,6 +24,7 @@ from db import database_extensions_available, db
from extensions import limiter from extensions import limiter
account_bp = Blueprint("account", __name__, url_prefix="/account") account_bp = Blueprint("account", __name__, url_prefix="/account")
EMAIL_VERIFICATION_TTL = timedelta(minutes=15)
def db_ready() -> bool: def db_ready() -> bool:
@@ -41,6 +44,53 @@ def safe_next_url(default: str) -> str:
return next_url 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("/") @account_bp.get("/")
@login_required @login_required
def home(): def home():
@@ -78,10 +128,8 @@ def register():
flash("An account already exists for that email.", "error") flash("An account already exists for that email.", "error")
else: else:
user = create_user(email, password) user = create_user(email, password)
db.session.commit() db.session.flush()
log_user_in(user.id) return redirect(_start_email_verification(user, safe_next_url(url_for("leagues.new"))))
flash("Account created.", "success")
return redirect(safe_next_url(url_for("leagues.new")))
return render_template("account_register.html", form=form) return render_template("account_register.html", form=form)
@@ -110,6 +158,15 @@ def login():
flash("Invalid email or password.", "error") flash("Invalid email or password.", "error")
elif user.disabled_at is not None: elif user.disabled_at is not None:
flash("That account is disabled.", "error") 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: else:
log_user_in(user.id) log_user_in(user.id)
next_url = safe_next_url(url_for("leagues.index")) next_url = safe_next_url(url_for("leagues.index"))
@@ -119,6 +176,82 @@ def login():
return render_template("account_login.html", form=form) 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") @account_bp.post("/logout")
def logout(): def logout():
log_user_out() log_user_out()
+28
View File
@@ -5467,6 +5467,34 @@ select.control { cursor: pointer; }
color: var(--faintest-2); 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 { .auth-links {
margin: -8px 0 16px; margin: -8px 0 16px;
text-align: right; text-align: right;
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Verify Email · myboker.org{% endblock %}
{% block page_class %}page--auth{% endblock %}
{% block content %}
<div class="auth-split">
<div class="auth-split__form-col">
<div class="auth-split__form-inner">
<a class="auth-split__brand" href="{{ url_for('public.home') }}">
<div class="auth-split__brand-mark"></div>
<span class="auth-split__brand-name">myboker<span class="auth-split__brand-tld">.org</span></span>
</a>
<h1 class="auth-split__title">Verify your email.</h1>
<p class="auth-split__sub">Enter the six-digit code sent to {{ email }}.</p>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="auth-field auth-field--code">
<label for="code">Verification code</label>
<input id="code" class="auth-code-input" type="text" name="code"
inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}"
maxlength="6" placeholder="000000" required autofocus>
</div>
<button class="btn btn--primary auth-submit" type="submit">Continue</button>
</form>
<form method="post" action="{{ url_for('account.resend_verification_code') }}" class="auth-resend-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="auth-link-button" type="submit">Resend code</button>
</form>
</div>
</div>
<div class="auth-split__visual auth-split__visual--diamond" aria-hidden="true">
<div class="auth-visual__body">
<div class="auth-visual__icon">
<span class="auth-visual__suit-sym"></span>
</div>
<p class="auth-visual__title">One quick check.</p>
<p class="auth-visual__sub">Email verification keeps league invites tied to the right account.</p>
</div>
</div>
</div>
{% endblock %}