feat(auth): add email verification flow

This commit is contained in:
2026-09-01 08:47:09 +03:00
parent 2dc4dedb2b
commit 65bebd19c5
9 changed files with 226 additions and 17 deletions
+116 -9
View File
@@ -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)