diff --git a/README.md b/README.md index 862152d..6a38ad8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/boker/__init__.py b/boker/__init__.py index b68942d..493a4e0 100644 --- a/boker/__init__.py +++ b/boker/__init__.py @@ -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) diff --git a/boker/auth.py b/boker/auth.py index 14c9794..2dfeeba 100644 --- a/boker/auth.py +++ b/boker/auth.py @@ -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}, diff --git a/boker/config.py b/boker/config.py index a5f5c85..68fed14 100644 --- a/boker/config.py +++ b/boker/config.py @@ -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): diff --git a/boker/emails.py b/boker/emails.py index 7b02ddf..bc3a20d 100644 --- a/boker/emails.py +++ b/boker/emails.py @@ -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) diff --git a/boker/routes/account.py b/boker/routes/account.py index 4eee3b3..3fb2254 100644 --- a/boker/routes/account.py +++ b/boker/routes/account.py @@ -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/") +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")) diff --git a/boker/routes/site_admin.py b/boker/routes/site_admin.py index 1e9f4e7..8a1f538 100644 --- a/boker/routes/site_admin.py +++ b/boker/routes/site_admin.py @@ -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//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//disable") @site_admin_required def disable_user(user_id: str): diff --git a/boker/static/css/admin.css b/boker/static/css/admin.css index 2b40188..7eeaae6 100644 --- a/boker/static/css/admin.css +++ b/boker/static/css/admin.css @@ -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 { diff --git a/boker/static/css/style.css b/boker/static/css/style.css index bc6975b..c8cd187 100644 --- a/boker/static/css/style.css +++ b/boker/static/css/style.css @@ -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; } diff --git a/boker/templates/account_settings.html b/boker/templates/account_settings.html index 8bbe18d..6b9b367 100644 --- a/boker/templates/account_settings.html +++ b/boker/templates/account_settings.html @@ -16,12 +16,29 @@
+ {% if not user.email_verified_at %} +
+ +
+

Verification

+

Email not verified

+
+

Your email address hasn't been verified yet. Check your inbox or resend the email below.

+
+ +
+
+ {% endif %} +

Profile

Email address

+ {% if user.email_verified_at %} +

Verified on {{ user.email_verified_at.strftime('%b %d, %Y') }}

+ {% endif %}
+
+
Email verified
+
+ {% if user.email_verified_at %} + Yes + {{ user.email_verified_at.strftime('%b %d, %Y at %H:%M UTC') }} + {% else %} + No + {% endif %} +
+
Status
@@ -82,6 +93,18 @@ {% endif %}
+
+
Email verification
+
+ + + + +
+
+
Set password directly
diff --git a/boker/templates/admin/users.html b/boker/templates/admin/users.html index 80cc6c3..9a904b5 100644 --- a/boker/templates/admin/users.html +++ b/boker/templates/admin/users.html @@ -28,6 +28,7 @@ {{ user.email }} {% if user.is_site_admin %}admin{% endif %} + {% if not user.email_verified_at %}unverified{% endif %} {{ user.created_at.strftime('%b %d, %Y') }} diff --git a/boker/templates/base.html b/boker/templates/base.html index 1cb7909..2a261a8 100644 --- a/boker/templates/base.html +++ b/boker/templates/base.html @@ -63,6 +63,16 @@ {% endif %} {% endwith %} + {% if is_logged_in and email_verified is not none and not email_verified %} +
+ Your email address hasn't been verified. + + + + +
+ {% endif %} + {% block content %}{% endblock %}