email verification

This commit is contained in:
SowinskiBraeden committed 2026-06-24 23:44:36 -07:00
1 parent 5c7a498af6
commit 54fa80f06e
14 files changed
+363 -5

No files matched your search

+2
View File
@@ -85,9 +85,11 @@ All variables are read from `.env` at startup. Copy `.env.example` as a starting
| `MAIL_SERVER` | No | _(empty)_ | SMTP server hostname. Email features are disabled if left blank. |
| `MAIL_PORT` | No | `587` | SMTP port. |
| `MAIL_USE_TLS` | No | `true` | Set to `false` to disable STARTTLS. |
| `MAIL_USE_SSL` | No | `false` | Set to `true` for implicit TLS SMTP, commonly port `465`. |
| `MAIL_USERNAME` | No | _(empty)_ | SMTP username / API key. |
| `MAIL_PASSWORD` | No | _(empty)_ | SMTP password / API key secret. |
| `MAIL_DEFAULT_SENDER` | No | `noreply@myboker.org` | From address on outgoing mail. |
| `MAIL_SEND_TIMEOUT` | No | `5` | Maximum seconds to wait on an SMTP send attempt. |
| `FLASK_DEBUG` | No | `0` | Set to `1` to enable the Flask reloader and debugger. |
## Running tests
+10 -2
View File
@@ -36,10 +36,18 @@ def create_app(config_overrides: dict | None = None) -> Flask:
@app.context_processor
def inject_globals() -> dict:
uid = current_user_id()
email_verified = None
if uid and db is not None and database_extensions_available():
from .db_models import User
user = db.session.get(User, uid)
if user is not None:
email_verified = user.email_verified_at is not None
return {
"app_version": app.config["APP_VERSION"],
"current_user_id": current_user_id(),
"is_logged_in": is_logged_in(),
"current_user_id": uid,
"is_logged_in": uid is not None,
"email_verified": email_verified,
}
app.register_blueprint(public_bp)
+11
View File
@@ -23,6 +23,17 @@ def verify_reset_token(token: str, max_age: int = 3600) -> str | None:
return None
def generate_email_verification_token(user_id: str) -> str:
return _serializer().dumps(user_id, salt="email-verify")
def verify_email_verification_token(token: str, max_age: int = 86400) -> str | None:
try:
return _serializer().loads(token, salt="email-verify", max_age=max_age)
except (SignatureExpired, BadSignature):
return None
def generate_invite_token(league_id: str, email: str, role: str, invited_by_user_id: str) -> str:
return _serializer().dumps(
{"league_id": league_id, "email": email, "role": role, "invited_by": invited_by_user_id},
+2
View File
@@ -44,9 +44,11 @@ class Config:
MAIL_SERVER: str = os.getenv("MAIL_SERVER", "")
MAIL_PORT: int = int(os.getenv("MAIL_PORT", "587"))
MAIL_USE_TLS: bool = os.getenv("MAIL_USE_TLS", "true").lower() in ("true", "1", "yes")
MAIL_USE_SSL: bool = os.getenv("MAIL_USE_SSL", "false").lower() in ("true", "1", "yes")
MAIL_USERNAME: str | None = os.getenv("MAIL_USERNAME") or None
MAIL_PASSWORD: str | None = os.getenv("MAIL_PASSWORD") or None
MAIL_DEFAULT_SENDER: str = os.getenv("MAIL_DEFAULT_SENDER", "noreply@myboker.org")
MAIL_SEND_TIMEOUT: float = float(os.getenv("MAIL_SEND_TIMEOUT", "5"))
class ProductionConfig(Config):
+48 -2
View File
@@ -1,12 +1,43 @@
#!/usr/bin/env python3
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
import socket
from flask import current_app
from flask_mail import Message
from .extensions import mail
class MailNotConfiguredError(RuntimeError):
pass
@contextmanager
def _mail_socket_timeout() -> Iterator[None]:
timeout = current_app.config.get("MAIL_SEND_TIMEOUT")
if timeout is None:
yield
return
previous_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(float(timeout))
try:
yield
finally:
socket.setdefaulttimeout(previous_timeout)
def _send(msg: Message) -> None:
if not str(current_app.config.get("MAIL_SERVER", "")).strip():
raise MailNotConfiguredError("MAIL_SERVER is not configured.")
with _mail_socket_timeout():
mail.send(msg)
def send_password_reset(to_email: str, reset_url: str) -> None:
msg = Message(
subject="Reset your myboker.org password",
@@ -19,7 +50,22 @@ def send_password_reset(to_email: str, reset_url: str) -> None:
),
sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
)
mail.send(msg)
_send(msg)
def send_email_verification(to_email: str, verify_url: str) -> None:
msg = Message(
subject="Verify your myboker.org email address",
recipients=[to_email],
body=(
f"Thanks for signing up for myboker.org!\n\n"
f"Please verify your email address by clicking the link below:\n\n"
f"{verify_url}\n\n"
f"This link expires in 24 hours. If you did not create an account, you can ignore this email."
),
sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
)
_send(msg)
def send_league_invite(to_email: str, league_name: str, invite_url: str, invited_by_email: str) -> None:
@@ -34,4 +80,4 @@ def send_league_invite(to_email: str, league_name: str, invite_url: str, invited
),
sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
)
mail.send(msg)
_send(msg)
+76 -1
View File
@@ -7,6 +7,7 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for
from ..auth import (
current_user_id,
generate_email_verification_token,
generate_invite_token,
generate_reset_token,
hash_password,
@@ -14,6 +15,7 @@ from ..auth import (
log_user_out,
login_required,
normalize_email,
verify_email_verification_token,
verify_invite_token,
verify_password,
verify_reset_token,
@@ -67,7 +69,16 @@ def register():
user = create_user(email, password)
db.session.commit()
log_user_in(user.id)
flash("Account created.", "success")
try:
from flask import current_app
from ..emails import send_email_verification
token = generate_email_verification_token(user.id)
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
verify_url = f"{base_url}{url_for('account.verify_email', token=token)}"
send_email_verification(user.email, verify_url)
except Exception:
pass
flash("Account created. Check your email to verify your address.", "success")
return redirect(url_for("leagues.new"))
return render_template("account_register.html", form=form)
@@ -343,3 +354,67 @@ def accept_invite(token):
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))
@account_bp.get("/verify-email/<token>")
def verify_email(token):
user_id = verify_email_verification_token(token)
if user_id is None:
flash("That verification link is invalid or has expired.", "error")
return redirect(url_for("account.settings") if current_user_id() else url_for("account.login"))
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("public.home"))
from ..db_models import User, utc_now
user = db.session.get(User, user_id)
if user is None or user.disabled_at is not None:
flash("That verification link is invalid or has expired.", "error")
return redirect(url_for("public.home"))
if user.email_verified_at is not None:
flash("Your email address is already verified.", "info")
return redirect(url_for("account.settings"))
user.email_verified_at = utc_now()
db.session.commit()
flash("Email address verified.", "success")
return redirect(url_for("account.settings"))
@account_bp.post("/resend-verification")
@login_required
def resend_verification():
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.settings"))
from flask import current_app
from ..db_models import User
from ..emails import MailNotConfiguredError, send_email_verification
user = db.session.get(User, current_user_id())
if user is None:
flash("User not found.", "error")
return redirect(url_for("account.settings"))
if user.email_verified_at is not None:
flash("Your email address is already verified.", "info")
return redirect(url_for("account.settings"))
try:
token = generate_email_verification_token(user.id)
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
verify_url = f"{base_url}{url_for('account.verify_email', token=token)}"
send_email_verification(user.email, verify_url)
flash("Verification email sent. Check your inbox.", "success")
except MailNotConfiguredError:
flash("Could not send verification email. Please try again later.", "error")
except Exception:
current_app.logger.exception("Failed to send verification email to %s", user.email)
flash("Could not send verification email. Please try again later.", "error")
return redirect(url_for("account.settings"))
+37
View File
@@ -283,6 +283,43 @@ def send_reset(user_id: str):
return redirect(url_for("site_admin.user_detail", user_id=user_id, show_reset="1"))
@site_admin_bp.post("/users/<user_id>/send-verification")
@site_admin_required
def send_verification(user_id: str):
from flask import current_app
from ..auth import generate_email_verification_token
from ..db_models import User
from ..emails import MailNotConfiguredError, send_email_verification
user = db.session.get(User, user_id)
if user is None:
flash("User not found.", "error")
return redirect(url_for("site_admin.users"))
if user.disabled_at is not None:
flash("Cannot send a verification email to a disabled account.", "error")
return redirect(url_for("site_admin.user_detail", user_id=user_id))
if user.email_verified_at is not None:
flash("That account's email is already verified.", "error")
return redirect(url_for("site_admin.user_detail", user_id=user_id))
try:
token = generate_email_verification_token(user.id)
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
verify_url = f"{base_url}{url_for('account.verify_email', token=token)}"
send_email_verification(user.email, verify_url)
flash(f"Verification email sent to {user.email}.", "success")
except MailNotConfiguredError:
flash("Email not sent (mail not configured).", "error")
except Exception:
current_app.logger.exception("Failed to send verification email to %s", user.email)
flash("Email not sent (mail not configured).", "error")
return redirect(url_for("site_admin.user_detail", user_id=user_id))
@site_admin_bp.post("/users/<user_id>/disable")
@site_admin_required
def disable_user(user_id: str):
+1
View File
@@ -372,6 +372,7 @@
.adm-pill--manager { background: var(--warn-tint); color: var(--warn); border: 1px solid var(--warn-tint-bd); }
.adm-pill--viewer { background: var(--surface-raised); color: var(--faintest); border: 1px solid var(--border); }
.adm-pill--archived{ background: var(--surface-raised); color: var(--faintest-2); border: 1px solid var(--border); }
.adm-pill--warn { background: var(--warn-tint); color: var(--warn); border: 1px solid var(--warn-tint-bd); }
/* ---- search bar ---- */
.adm-search {
+41
View File
@@ -3359,3 +3359,44 @@ select.control { cursor: pointer; }
.card-menu__item:hover { background: var(--field); color: var(--text); }
.card-menu__item--warn { color: var(--neg); }
.card-menu__item--warn:hover { background: var(--neg-tint); color: var(--neg); }
/* ================================================================
EMAIL VERIFICATION
================================================================ */
.verify-banner {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 20px;
background: var(--warn-tint);
border-bottom: 1px solid var(--warn-tint-bd);
font: 400 13px/1.4 var(--font-ui);
color: var(--warn);
flex-wrap: wrap;
}
.verify-banner__btn {
all: unset;
cursor: pointer;
font: 400 13px/1.4 var(--font-ui);
color: var(--warn);
text-decoration: underline;
white-space: nowrap;
}
.verify-banner__btn:hover { opacity: .8; }
.verify-status {
font: 400 12px/1 var(--font-ui);
margin: 0 0 4px;
letter-spacing: .02em;
}
.verify-status--ok { color: var(--pos); }
.verify-status--warn { color: var(--warn); }
.link-btn {
all: unset;
cursor: pointer;
font: inherit;
color: inherit;
text-decoration: underline;
}
.link-btn:hover { opacity: .8; }
+17
View File
@@ -16,12 +16,29 @@
<div class="stack">
{% if not user.email_verified_at %}
<form class="panel form-card" method="post" action="{{ url_for('account.resend_verification') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<p class="eyebrow" style="color:var(--warn);">Verification</p>
<h2 class="panel__title" style="margin-top:4px;">Email not verified</h2>
</div>
<p class="muted-text" style="margin:0;">Your email address hasn't been verified yet. Check your inbox or resend the email below.</p>
<div>
<button class="btn btn--primary" type="submit">Resend verification email</button>
</div>
</form>
{% endif %}
<form class="panel form-card" method="post" action="{{ url_for('account.update_email') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<p class="eyebrow">Profile</p>
<h2 class="panel__title" style="margin-top:4px;">Email address</h2>
</div>
{% if user.email_verified_at %}
<p class="verify-status verify-status--ok">Verified on {{ user.email_verified_at.strftime('%b %d, %Y') }}</p>
{% endif %}
<label>
<span>Email</span>
<input type="email" name="email" value="{{ user.email }}" required autocomplete="email">
+23
View File
@@ -30,6 +30,17 @@
<dt>Last login</dt>
<dd>{% if user.last_login_at %}{{ user.last_login_at.strftime('%b %d, %Y at %H:%M UTC') }}{% else %}<span style="color:var(--faintest)">Never</span>{% endif %}</dd>
</div>
<div class="adm-kv-row">
<dt>Email verified</dt>
<dd>
{% if user.email_verified_at %}
<span class="adm-pill adm-pill--active">Yes</span>
<span style="color:var(--faintest);font-size:12px;margin-left:6px;">{{ user.email_verified_at.strftime('%b %d, %Y at %H:%M UTC') }}</span>
{% else %}
<span class="adm-pill adm-pill--warn">No</span>
{% endif %}
</dd>
</div>
<div class="adm-kv-row">
<dt>Status</dt>
<dd>
@@ -82,6 +93,18 @@
{% endif %}
</div>
<div class="adm-section" style="margin-bottom:20px;">
<div class="adm-section__title" style="margin-bottom:8px;">Email verification</div>
<div class="adm-actions">
<form class="adm-inline-form" method="post" action="{{ url_for('site_admin.send_verification', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="adm-btn adm-btn--ghost" type="submit"{% if user.disabled_at or user.email_verified_at %} disabled title="{% if user.email_verified_at %}Already verified{% else %}Account is disabled{% endif %}"{% endif %}>
Send verification email
</button>
</form>
</div>
</div>
<div class="adm-section" style="margin-bottom:20px;">
<div class="adm-section__title" style="margin-bottom:8px;">Set password directly</div>
<form class="adm-pass-form" method="post" action="{{ url_for('site_admin.admin_reset_password', user_id=user.id) }}">
+1
View File
@@ -28,6 +28,7 @@
<td>
<a href="{{ url_for('site_admin.user_detail', user_id=user.id) }}">{{ user.email }}</a>
{% if user.is_site_admin %}<span class="adm-pill adm-pill--admin" style="margin-left:4px;">admin</span>{% endif %}
{% if not user.email_verified_at %}<span class="adm-pill adm-pill--warn" style="margin-left:4px;">unverified</span>{% endif %}
</td>
<td class="cell-muted">{{ user.created_at.strftime('%b %d, %Y') }}</td>
<td class="cell-muted">
+10
View File
@@ -63,6 +63,16 @@
{% endif %}
{% endwith %}
{% if is_logged_in and email_verified is not none and not email_verified %}
<div class="verify-banner">
<span>Your email address hasn't been verified.</span>
<form method="post" action="{{ url_for('account.resend_verification') }}" style="margin:0;display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="verify-banner__btn" type="submit">Resend verification email</button>
</form>
</div>
{% endif %}
{% block content %}{% endblock %}
<footer class="site-footer" aria-label="Site footer">
+84
View File
@@ -0,0 +1,84 @@
import socket
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from flask_mail import Message
from boker import create_app
from boker.db import db
from boker.emails import MailNotConfiguredError, send_email_verification
from boker.repositories.leagues import create_user
class EmailDeliveryTests(unittest.TestCase):
def test_blank_mail_server_fails_before_smtp_send(self):
app = create_app({"MAIL_SERVER": "", "TESTING": True})
with app.app_context():
with patch("boker.emails.mail.send") as send:
with self.assertRaises(MailNotConfiguredError):
send_email_verification("user@example.com", "http://localhost/verify")
send.assert_not_called()
def test_smtp_send_uses_configured_socket_timeout(self):
app = create_app({"MAIL_SERVER": "smtp.example.com", "MAIL_SEND_TIMEOUT": 1.5, "TESTING": True})
msg = Message(
subject="Test",
recipients=["user@example.com"],
body="body",
sender="noreply@example.com",
)
observed_timeout = None
def capture_timeout(_msg):
nonlocal observed_timeout
observed_timeout = socket.getdefaulttimeout()
with app.app_context():
previous_timeout = socket.getdefaulttimeout()
with patch("boker.emails.mail.send", side_effect=capture_timeout):
from boker.emails import _send
_send(msg)
self.assertEqual(observed_timeout, 1.5)
self.assertEqual(socket.getdefaulttimeout(), previous_timeout)
def test_resend_verification_returns_when_mail_is_disabled(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.sqlite3"
app = create_app(
{
"SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}",
"MAIL_SERVER": "",
"TESTING": True,
"WTF_CSRF_ENABLED": False,
}
)
with app.app_context():
db.create_all()
user = create_user("user@example.com", "password123")
db.session.commit()
user_id = user.id
with app.test_client() as client:
with client.session_transaction() as flask_session:
flask_session["user_id"] = user_id
response = client.post("/account/resend-verification")
with app.app_context():
db.session.remove()
db.drop_all()
db.engine.dispose()
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "/account/settings")
if __name__ == "__main__":
unittest.main()