diff --git a/README.md b/README.md index ec10ea3..b8de0da 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/TODO.md b/TODO.md index b65ddc7..1b2b20b 100644 --- a/TODO.md +++ b/TODO.md @@ -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. \ No newline at end of file diff --git a/app.py b/app.py index 2826882..4abcb23 100644 --- a/app.py +++ b/app.py @@ -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/", 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) diff --git a/data/eternity.db b/data/eternity.db index dd14e92..1cbbb31 100644 Binary files a/data/eternity.db and b/data/eternity.db differ diff --git a/templates/admin_users.html b/templates/admin_users.html index fe43551..52f3797 100644 --- a/templates/admin_users.html +++ b/templates/admin_users.html @@ -7,7 +7,7 @@
HANDLE / EMAILREGISTEREDMFASTATUS
{% for user in users %}
- {{ user.username }}{% if user.role == 'admin' %} ADMIN{% endif %} + {{ user.username }}{% if user.role == 'admin' %} ADMIN{% endif %} {{ user.created_at[:10] }} {{ 'ENABLED' if user.mfa_enabled else 'PENDING' }} {% if user.is_approved and user.role == 'member' %}
{% elif user.is_approved %}APPROVED{% else %}
{% endif %}
diff --git a/templates/auth.html b/templates/auth.html index e1514c7..d2b5034 100644 --- a/templates/auth.html +++ b/templates/auth.html @@ -2,14 +2,14 @@ {% block title %}{{ 'Create account' if mode == 'register' else 'Sign in' }} | Eternity Project{% endblock %} {% block content %}
-
MEMBER ACCESS

{{ 'Join the circuit.' if mode == 'register' else 'Resume the signal.' }}

Accounts let you publish and maintain your own engineering notes.

+
MEMBER ACCESS

{% if mode == 'register' %}Join the circuit.{% elif mode == 'resend' %}Verify the signal.{% else %}Resume the signal.{% endif %}

{% 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 %}

{% if mode == 'register' %}{% endif %} - -

{% if mode == 'register' %}Already publishing? Sign in{% else %}New to the project? Create an account
Forgot your password?{% endif %}

+ +

{% if mode == 'register' %}Already publishing? Sign in{% elif mode == 'resend' %}Back to sign in{% else %}New to the project? Create an account
Forgot your password?
Resend verification email{% endif %}

{% endblock %} diff --git a/templates/email_form.html b/templates/email_form.html index a6f880d..58dd65a 100644 --- a/templates/email_form.html +++ b/templates/email_form.html @@ -2,13 +2,13 @@ {% block title %}Email address | Eternity Project{% endblock %} {% block content %}
-
ACCOUNT DETAILS

Update your
address.

Use an email address you control. Confirm your current password before saving the change.

+
ACCOUNT DETAILS

Update your
address.

Use an email address you control. Confirm your password, then verify the link sent to the new address.

- +
{% endblock %} \ No newline at end of file diff --git a/templates/email_verify.html b/templates/email_verify.html new file mode 100644 index 0000000..445cc8c --- /dev/null +++ b/templates/email_verify.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% block title %}Verify email | Eternity Project{% endblock %} +{% block content %} +
+
EMAIL VERIFICATION

Confirm the
signal.

{% 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 %}

+
+ + + +
+
+{% endblock %} \ No newline at end of file diff --git a/test_email_verification.py b/test_email_verification.py new file mode 100644 index 0000000..a144aa7 --- /dev/null +++ b/test_email_verification.py @@ -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() \ No newline at end of file