update email verification process
This commit is contained in:
1 parent
1b442011be
commit
ffc33c967a
7 files changed
+257
-6
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 %}
|
||||
Reference in new issue
Block a user