From 65bebd19c52c1cf2da440cc28f9d8abf1240a4e9 Mon Sep 17 00:00:00 2001 From: Tero Date: Tue, 1 Sep 2026 08:47:09 +0300 Subject: [PATCH] feat(auth): add email verification flow --- README.md | 2 +- TODO.md | 2 +- app.py | 125 +++++++++++++++++++++++++++++++++--- data/eternity.db | Bin 45056 -> 53248 bytes templates/admin_users.html | 2 +- templates/auth.html | 6 +- templates/email_form.html | 4 +- templates/email_verify.html | 12 ++++ test_email_verification.py | 90 ++++++++++++++++++++++++++ 9 files changed, 226 insertions(+), 17 deletions(-) create mode 100644 templates/email_verify.html create mode 100644 test_email_verification.py 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 dd14e92b5e65254b2af13028f44b9082d10307d0..1cbbb313074c512020cd2acd0b46a8590f461601 100644 GIT binary patch delta 359 zcmZp8z|^pSd4jYc7Xt$WFA&23=R_T2RW1g-E)iaiat0oz=L}5G`Ooq`Vw=OeolAy$ z8GjM?Pu>ca?fmi^8IUEJQDu`ReHF)1e%SwV7QNoIatd`W(GYF;r;2_EMlSH}=ng%C$4A6FC$Ao?d4 za^>+tbSI{y6r~myPyWv*Gxp xZ~n<`%f!L(8tf15$&DNj*#ddk#8d4j*YQf2;&fHKAx H{=NkOMkX4q 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 @@ {% for user in users %}