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'}"
|
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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