email verification
This commit is contained in:
1 parent
5c7a498af6
commit
54fa80f06e
14 files changed
+363
-5
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
@@ -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
@@ -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"))
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
@@ -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">
|
||||
|
||||
@@ -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) }}">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user