feat(auth): add email verification flow
This commit is contained in:
@@ -39,7 +39,7 @@ After it prints `ADMIN_PASSWORD_ROTATED_MFA_RESET`, sign in as `admin` with the
|
||||
|
||||
## Email and password recovery
|
||||
|
||||
Registration now requires an email address. Members can change their password from the navigation. The sign-in page provides an email-based recovery link; it expires after one hour and can only be used once. Administrators can send the same recovery email to any approved member from **Accounts**.
|
||||
Registration now requires an email address and a confirmation link before the account can sign in. Members can change their email address from the navigation; the current address remains active until the link sent to the new address is confirmed. Verification and password-reset links expire after 24 hours and one hour respectively, and can only be used once. Administrators can send the same recovery email to any approved member from **Accounts**.
|
||||
|
||||
All browser POST forms are protected by server-validated CSRF tokens.
|
||||
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
- [x] Add ordered inline step images with captions for long-form repair and engineering posts.
|
||||
- [ ] Add code snippets to long-form stories.
|
||||
- [x] Allow users to change their email addresses.
|
||||
- [ ] Add user email verification for registration and email-address changes.
|
||||
- [x] Add user email verification for registration and email-address changes.
|
||||
- [ ] Add user real names and use them as the published-by name.
|
||||
- [ ] Add ability to hide posts from unregistered users.
|
||||
@@ -91,6 +91,17 @@ def init_db():
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
email_address TEXT NOT NULL,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
token_type TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS post_images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL,
|
||||
@@ -109,11 +120,14 @@ def init_db():
|
||||
"mfa_secret": "ALTER TABLE users ADD COLUMN mfa_secret TEXT",
|
||||
"mfa_enabled": "ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0",
|
||||
"email": "ALTER TABLE users ADD COLUMN email TEXT",
|
||||
"email_verified": "ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0",
|
||||
}
|
||||
post_columns = {row["name"] for row in db.execute("PRAGMA table_info(posts)")}
|
||||
for column, statement in migrations.items():
|
||||
if column not in user_columns:
|
||||
db.execute(statement)
|
||||
if column == "email_verified":
|
||||
db.execute("UPDATE users SET email_verified = 1 WHERE email IS NOT NULL")
|
||||
if "image_filename" not in post_columns:
|
||||
db.execute("ALTER TABLE posts ADD COLUMN image_filename TEXT")
|
||||
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique ON users(email)")
|
||||
@@ -268,6 +282,41 @@ def send_reset_email(user):
|
||||
client.send_message(message)
|
||||
|
||||
|
||||
def create_email_verification_token(user_id, email_address, token_type):
|
||||
token = token_urlsafe(32)
|
||||
now = datetime.now(timezone.utc)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE email_verification_tokens SET used_at = ? WHERE user_id = ? AND token_type = ? AND used_at IS NULL",
|
||||
(now.isoformat(), user_id, token_type),
|
||||
)
|
||||
db.execute(
|
||||
"""INSERT INTO email_verification_tokens (user_id, email_address, token_hash, token_type, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, email_address, sha256(token.encode("utf-8")).hexdigest(), token_type, (now + timedelta(hours=24)).isoformat(), now.isoformat()),
|
||||
)
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def send_verification_email(user, email_address, token_type):
|
||||
if not app.config["MAIL_HOST"]:
|
||||
raise RuntimeError("Email is not configured. Set MAIL_HOST and related SMTP settings.")
|
||||
token = create_email_verification_token(user["id"], email_address, token_type)
|
||||
verification_url = f"{app.config['PUBLIC_URL']}{url_for('verify_email', token=token)}"
|
||||
action = "confirm this email address for your account" if token_type == "registration" else "confirm this new email address"
|
||||
message = EmailMessage()
|
||||
message["Subject"] = "Confirm your Eternity Project email address"
|
||||
message["From"] = app.config["MAIL_FROM"]
|
||||
message["To"] = email_address
|
||||
message.set_content(f"Hello {user['username']},\n\nUse this link within 24 hours to {action}:\n{verification_url}\n\nIf you did not request this, you can ignore this email.")
|
||||
with smtplib.SMTP(app.config["MAIL_HOST"], app.config["MAIL_PORT"]) as client:
|
||||
client.starttls()
|
||||
if app.config["MAIL_USERNAME"]:
|
||||
client.login(app.config["MAIL_USERNAME"], app.config["MAIL_PASSWORD"])
|
||||
client.send_message(message)
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_current_user():
|
||||
user = None
|
||||
@@ -317,12 +366,18 @@ def register():
|
||||
flash("Enter a valid email address.", "error")
|
||||
else:
|
||||
try:
|
||||
get_db().execute(
|
||||
cursor = get_db().execute(
|
||||
"INSERT INTO users (username, email, password_hash, created_at, is_approved) VALUES (?, ?, ?, ?, 0)",
|
||||
(username, email, generate_password_hash(password), datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
get_db().commit()
|
||||
flash("Registration received. An administrator must approve it before you can sign in.", "success")
|
||||
user = {"id": cursor.lastrowid, "username": username}
|
||||
try:
|
||||
send_verification_email(user, email, "registration")
|
||||
flash("Check your email to verify your address. An administrator must also approve your account before you can sign in.", "success")
|
||||
except (RuntimeError, OSError, smtplib.SMTPException):
|
||||
app.logger.exception("Unable to send registration verification email")
|
||||
flash("Registration received, but the verification email could not be sent. Contact an administrator.", "error")
|
||||
return redirect(url_for("login"))
|
||||
except sqlite3.IntegrityError:
|
||||
flash("That handle or email address is already in use.", "error")
|
||||
@@ -338,6 +393,8 @@ def login():
|
||||
flash("Invalid handle or password.", "error")
|
||||
elif not user["is_approved"]:
|
||||
flash("Your account is awaiting administrator approval.", "notice")
|
||||
elif user["email"] and not user["email_verified"]:
|
||||
flash("Verify your email address before you can sign in.", "notice")
|
||||
else:
|
||||
session.clear()
|
||||
session["mfa_pending_user_id"] = user["id"]
|
||||
@@ -348,6 +405,52 @@ def login():
|
||||
return render_template("auth.html", mode="login")
|
||||
|
||||
|
||||
@app.route("/email/resend", methods=("GET", "POST"))
|
||||
def resend_verification_email():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip().lower()
|
||||
user = get_db().execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
|
||||
if user is not None and user["email"] and not user["email_verified"] and check_password_hash(user["password_hash"], request.form.get("password", "")):
|
||||
try:
|
||||
send_verification_email(user, user["email"], "registration")
|
||||
except (RuntimeError, OSError, smtplib.SMTPException):
|
||||
app.logger.exception("Unable to resend verification email")
|
||||
flash("If those credentials belong to an unverified account, a verification link has been sent.", "success")
|
||||
return redirect(url_for("login"))
|
||||
return render_template("auth.html", mode="resend")
|
||||
|
||||
|
||||
@app.route("/email/verify/<token>", methods=("GET", "POST"))
|
||||
def verify_email(token):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
verification = get_db().execute(
|
||||
"""SELECT * FROM email_verification_tokens
|
||||
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?""",
|
||||
(sha256(token.encode("utf-8")).hexdigest(), now),
|
||||
).fetchone()
|
||||
if verification is None:
|
||||
flash("That email verification link is invalid or has expired.", "error")
|
||||
return redirect(url_for("login"))
|
||||
if request.method == "POST":
|
||||
db = get_db()
|
||||
try:
|
||||
if verification["token_type"] == "registration":
|
||||
db.execute("UPDATE users SET email_verified = 1 WHERE id = ?", (verification["user_id"],))
|
||||
message = "Email verified. You can sign in once an administrator approves your account."
|
||||
else:
|
||||
db.execute("UPDATE users SET email = ?, email_verified = 1 WHERE id = ?", (verification["email_address"], verification["user_id"]))
|
||||
message = "Email address updated and verified."
|
||||
db.execute("UPDATE email_verification_tokens SET used_at = ? WHERE id = ?", (now, verification["id"]))
|
||||
db.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
db.rollback()
|
||||
flash("That email address is already in use. Request a verification link for a different address.", "error")
|
||||
return redirect(url_for("change_email"))
|
||||
flash(message, "success")
|
||||
return redirect(url_for("login"))
|
||||
return render_template("email_verify.html", token=token, email=verification["email_address"], token_type=verification["token_type"])
|
||||
|
||||
|
||||
@app.route("/password/forgot", methods=("GET", "POST"))
|
||||
def forgot_password():
|
||||
if request.method == "POST":
|
||||
@@ -409,7 +512,7 @@ def change_password():
|
||||
@app.route("/account/email", methods=("GET", "POST"))
|
||||
@login_required
|
||||
def change_email():
|
||||
user = get_db().execute("SELECT email, password_hash FROM users WHERE id = ?", (session["user_id"],)).fetchone()
|
||||
user = get_db().execute("SELECT id, username, email, password_hash FROM users WHERE id = ?", (session["user_id"],)).fetchone()
|
||||
if request.method == "POST":
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
current_password = request.form.get("current_password", "")
|
||||
@@ -421,12 +524,16 @@ def change_email():
|
||||
flash("That is already your email address.", "notice")
|
||||
else:
|
||||
try:
|
||||
get_db().execute("UPDATE users SET email = ? WHERE id = ?", (email, session["user_id"]))
|
||||
get_db().commit()
|
||||
flash("Email address updated.", "success")
|
||||
existing_user = get_db().execute("SELECT id FROM users WHERE email = ? AND id != ?", (email, session["user_id"])).fetchone()
|
||||
if existing_user is not None:
|
||||
flash("That email address is already in use.", "error")
|
||||
return render_template("email_form.html", email=user["email"])
|
||||
send_verification_email(user, email, "email_change")
|
||||
flash("Verification email sent. Confirm the new address within 24 hours to complete the change.", "success")
|
||||
return redirect(url_for("index"))
|
||||
except sqlite3.IntegrityError:
|
||||
flash("That email address is already in use.", "error")
|
||||
except (RuntimeError, OSError, smtplib.SMTPException):
|
||||
app.logger.exception("Unable to send email change verification email")
|
||||
flash("Verification email could not be sent. Check SMTP settings and try again.", "error")
|
||||
return render_template("email_form.html", email=user["email"])
|
||||
|
||||
|
||||
@@ -492,7 +599,7 @@ def mfa_verify():
|
||||
@admin_required
|
||||
def admin_users():
|
||||
users = get_db().execute(
|
||||
"SELECT id, username, email, created_at, is_approved, mfa_enabled, role FROM users ORDER BY is_approved, created_at DESC"
|
||||
"SELECT id, username, email, email_verified, created_at, is_approved, mfa_enabled, role FROM users ORDER BY is_approved, created_at DESC"
|
||||
).fetchall()
|
||||
return render_template("admin_users.html", users=users)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
<div class="account-row account-head" role="row"><span>HANDLE / EMAIL</span><span>REGISTERED</span><span>MFA</span><span>STATUS</span></div>
|
||||
{% for user in users %}
|
||||
<div class="account-row" role="row">
|
||||
<strong>{{ user.username }}{% if user.role == 'admin' %} <small>ADMIN</small>{% endif %}<small class="email">{{ user.email or 'NO EMAIL' }}</small></strong>
|
||||
<strong>{{ user.username }}{% if user.role == 'admin' %} <small>ADMIN</small>{% endif %}<small class="email">{{ user.email or 'NO EMAIL' }}{% if user.email %} / {{ 'VERIFIED' if user.email_verified else 'UNVERIFIED' }}{% endif %}</small></strong>
|
||||
<span>{{ user.created_at[:10] }}</span>
|
||||
<span>{{ 'ENABLED' if user.mfa_enabled else 'PENDING' }}</span>
|
||||
<span>{% if user.is_approved and user.role == 'member' %}<form method="post" action="{{ url_for('admin_password_reset', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Reset password</button></form>{% elif user.is_approved %}APPROVED{% else %}<form method="post" action="{{ url_for('approve_user', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Approve</button></form>{% endif %}</span>
|
||||
|
||||
+3
-3
@@ -2,14 +2,14 @@
|
||||
{% block title %}{{ 'Create account' if mode == 'register' else 'Sign in' }} | Eternity Project{% endblock %}
|
||||
{% block content %}
|
||||
<section class="auth-layout">
|
||||
<div class="auth-copy"><span class="signal-label">MEMBER ACCESS</span><h1>{{ 'Join the circuit.' if mode == 'register' else 'Resume the signal.' }}</h1><p>Accounts let you publish and maintain your own engineering notes.</p></div>
|
||||
<div class="auth-copy"><span class="signal-label">MEMBER ACCESS</span><h1>{% if mode == 'register' %}Join the circuit.{% elif mode == 'resend' %}Verify the signal.{% else %}Resume the signal.{% endif %}</h1><p>{% if mode == 'resend' %}Enter your account credentials to receive a fresh verification link.{% else %}Accounts let you publish and maintain your own engineering notes.{% endif %}</p></div>
|
||||
<form class="auth-form" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label>Handle<input name="username" autocomplete="username" required pattern="[a-z0-9_-]{3,32}" placeholder="your-handle"></label>
|
||||
{% if mode == 'register' %}<label>Email<input name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>{% endif %}
|
||||
<label>Password<input name="password" type="password" autocomplete="{{ 'new-password' if mode == 'register' else 'current-password' }}" required {% if mode == 'register' %}minlength="10" placeholder="10+ characters"{% else %}placeholder="Your password"{% endif %}></label>
|
||||
<button class="button" type="submit">{{ 'Create account' if mode == 'register' else 'Sign in' }} <span aria-hidden="true">→</span></button>
|
||||
<p class="form-switch">{% if mode == 'register' %}Already publishing? <a href="{{ url_for('login') }}">Sign in</a>{% else %}New to the project? <a href="{{ url_for('register') }}">Create an account</a><br><a href="{{ url_for('forgot_password') }}">Forgot your password?</a>{% endif %}</p>
|
||||
<button class="button" type="submit">{% if mode == 'register' %}Create account{% elif mode == 'resend' %}Resend verification{% else %}Sign in{% endif %} <span aria-hidden="true">→</span></button>
|
||||
<p class="form-switch">{% if mode == 'register' %}Already publishing? <a href="{{ url_for('login') }}">Sign in</a>{% elif mode == 'resend' %}<a href="{{ url_for('login') }}">Back to sign in</a>{% else %}New to the project? <a href="{{ url_for('register') }}">Create an account</a><br><a href="{{ url_for('forgot_password') }}">Forgot your password?</a><br><a href="{{ url_for('resend_verification_email') }}">Resend verification email</a>{% endif %}</p>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
{% block title %}Email address | Eternity Project{% endblock %}
|
||||
{% block content %}
|
||||
<section class="auth-layout">
|
||||
<div class="auth-copy"><span class="signal-label">ACCOUNT DETAILS</span><h1>Update your<br>address.</h1><p>Use an email address you control. Confirm your current password before saving the change.</p></div>
|
||||
<div class="auth-copy"><span class="signal-label">ACCOUNT DETAILS</span><h1>Update your<br>address.</h1><p>Use an email address you control. Confirm your password, then verify the link sent to the new address.</p></div>
|
||||
<form class="auth-form" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label>Current email<input type="email" value="{{ email or '' }}" readonly></label>
|
||||
<label>New email<input name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>
|
||||
<label>Current password<input name="current_password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="button" type="submit">Save email <span aria-hidden="true">→</span></button>
|
||||
<button class="button" type="submit">Send verification <span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Verify email | Eternity Project{% endblock %}
|
||||
{% block content %}
|
||||
<section class="auth-layout">
|
||||
<div class="auth-copy"><span class="signal-label">EMAIL VERIFICATION</span><h1>Confirm the<br>signal.</h1><p>{% if token_type == 'registration' %}Confirm {{ email }} to activate your account after administrator approval.{% else %}Confirm {{ email }} to replace the email address connected to your account.{% endif %}</p></div>
|
||||
<form class="auth-form" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label>Email<input type="email" value="{{ email }}" readonly></label>
|
||||
<button class="button" type="submit">Confirm email <span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import app as application
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
|
||||
class EmailVerificationTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_directory = tempfile.TemporaryDirectory()
|
||||
self.database_path = Path(self.temp_directory.name) / "test.db"
|
||||
self.original_config = application.app.config.copy()
|
||||
application.app.config.update(
|
||||
DATABASE=self.database_path,
|
||||
MAIL_HOST="smtp.example.com",
|
||||
PUBLIC_URL="https://example.com",
|
||||
TESTING=True,
|
||||
WTF_CSRF_ENABLED=False,
|
||||
)
|
||||
with application.app.app_context():
|
||||
application.init_db()
|
||||
self.client = application.app.test_client()
|
||||
|
||||
def tearDown(self):
|
||||
application.app.config.update(self.original_config)
|
||||
self.temp_directory.cleanup()
|
||||
|
||||
def verification_token_from_message(self, message):
|
||||
match = re.search(r"/email/verify/([A-Za-z0-9_-]+)", message.get_content())
|
||||
self.assertIsNotNone(match)
|
||||
return match.group(1)
|
||||
|
||||
def test_registration_requires_email_verification_before_login(self):
|
||||
with patch("app.smtplib.SMTP") as smtp:
|
||||
response = self.client.post(
|
||||
"/register",
|
||||
data={"username": "newmember", "email": "new@example.com", "password": "secure-password"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
message = smtp.return_value.__enter__.return_value.send_message.call_args.args[0]
|
||||
token = self.verification_token_from_message(message)
|
||||
with application.app.app_context():
|
||||
user = application.get_db().execute("SELECT id, email_verified FROM users WHERE username = ?", ("newmember",)).fetchone()
|
||||
self.assertEqual(user["email_verified"], 0)
|
||||
application.get_db().execute("UPDATE users SET is_approved = 1 WHERE id = ?", (user["id"],))
|
||||
application.get_db().commit()
|
||||
|
||||
response = self.client.post("/login", data={"username": "newmember", "password": "secure-password"})
|
||||
self.assertIn(b"Verify your email address before you can sign in.", response.data)
|
||||
self.assertEqual(self.client.post(f"/email/verify/{token}").status_code, 302)
|
||||
with application.app.app_context():
|
||||
self.assertEqual(application.get_db().execute("SELECT email_verified FROM users WHERE id = ?", (user["id"],)).fetchone()[0], 1)
|
||||
|
||||
def test_email_change_keeps_current_address_until_confirmation(self):
|
||||
with application.app.app_context():
|
||||
db = application.get_db()
|
||||
cursor = db.execute(
|
||||
"""INSERT INTO users (username, email, email_verified, password_hash, created_at, is_approved, role, mfa_enabled)
|
||||
VALUES (?, ?, 1, ?, ?, 1, 'member', 1)""",
|
||||
("member", "old@example.com", generate_password_hash("secure-password"), "2026-09-01T00:00:00+00:00"),
|
||||
)
|
||||
db.commit()
|
||||
user_id = cursor.lastrowid
|
||||
with self.client.session_transaction() as session:
|
||||
session["user_id"] = user_id
|
||||
session["mfa_verified"] = True
|
||||
|
||||
with patch("app.smtplib.SMTP") as smtp:
|
||||
response = self.client.post(
|
||||
"/account/email",
|
||||
data={"email": "new@example.com", "current_password": "secure-password"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
message = smtp.return_value.__enter__.return_value.send_message.call_args.args[0]
|
||||
token = self.verification_token_from_message(message)
|
||||
with application.app.app_context():
|
||||
self.assertEqual(application.get_db().execute("SELECT email FROM users WHERE id = ?", (user_id,)).fetchone()[0], "old@example.com")
|
||||
|
||||
self.assertEqual(self.client.post(f"/email/verify/{token}").status_code, 302)
|
||||
with application.app.app_context():
|
||||
self.assertEqual(application.get_db().execute("SELECT email FROM users WHERE id = ?", (user_id,)).fetchone()[0], "new@example.com")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user