From 8a2cbe5b8bbd96373455a28950c352922377534c Mon Sep 17 00:00:00 2001 From: Tero Date: Sat, 29 Aug 2026 19:57:31 +0300 Subject: [PATCH] Add partial email support and todo list --- .env.example | 6 ++ README.md | 6 ++ TODO.md | 11 +++ __pycache__/app.cpython-314.pyc | Bin 20722 -> 31073 bytes app.py | 143 ++++++++++++++++++++++++++++++-- compose.yaml | 6 ++ data/eternity.db | Bin 24576 -> 36864 bytes static/css/site.css | 2 +- templates/admin_users.html | 6 +- templates/auth.html | 3 +- templates/base.html | 1 + templates/password_form.html | 13 +++ 12 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 TODO.md create mode 100644 templates/password_form.html diff --git a/.env.example b/.env.example index 9ec00ae..89138fd 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,8 @@ # Generate a long, random value before deploying. SECRET_KEY=replace-with-a-long-random-secret +PUBLIC_URL=https://eternityproject.fi +MAIL_HOST=smtp.example.com +MAIL_PORT=587 +MAIL_USERNAME=your-smtp-username +MAIL_PASSWORD=your-smtp-password +MAIL_FROM=noreply@eternityproject.fi diff --git a/README.md b/README.md index 5c13ae6..a98a32a 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ Open `http://localhost:8000`, create the first account, and publish a field note The initial administrator account is `admin` with password `admin`, as requested for first-run access. Sign in, scan the displayed QR code with any iPhone or Android TOTP authenticator, and change this password before exposing the service to the internet. New registrations are held for approval in **Accounts**; accepted users must enroll a TOTP authenticator before they can publish. +## 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**. + +Set `PUBLIC_URL` and the `MAIL_*` values in `.env` to send recovery emails. The SMTP account must support STARTTLS on the configured port. Review [TODO.md](TODO.md) before production deployment. + ## Production notes Put this service behind a TLS reverse proxy for `eternityproject.fi` (for example Caddy or Nginx). Set a strong unique `SECRET_KEY`; the Compose file intentionally refuses to start without it. Back up the `eternity_data` Docker volume, which contains accounts and posts. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..4ee4937 --- /dev/null +++ b/TODO.md @@ -0,0 +1,11 @@ +# Eternity Project TODO + +- [x] Dockerized Flask publishing service with persistent storage. +- [x] Local accounts, administrator approval, and TOTP MFA. +- [x] QR-based MFA enrollment for iPhone and Android authenticators. +- [x] Email addresses, member password changes, and expiring password-reset links. +- [ ] Configure production SMTP credentials and verify outgoing email delivery. +- [ ] Change the initial `admin` password and enroll its authenticator. +- [ ] Add CSRF protection to all state-changing forms. +- [ ] Add automated database backups and test restoration. +- [ ] Configure TLS reverse proxy and production domain for `eternityproject.fi`. \ No newline at end of file diff --git a/__pycache__/app.cpython-314.pyc b/__pycache__/app.cpython-314.pyc index 021daca687bee400f332d2430b60e1774eaa111d..51feb7da042d29bfdacd371e3ae4d3ad443e31fa 100644 GIT binary patch delta 12943 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 delta 5131 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 diff --git a/app.py b/app.py index fcd060b..9229910 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,15 @@ import os import re import sqlite3 +import smtplib from base64 import b64encode from io import BytesIO -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +from email.message import EmailMessage from functools import wraps from pathlib import Path +from secrets import token_urlsafe +from hashlib import sha256 from flask import Flask, abort, flash, g, redirect, render_template, request, session, url_for import pyotp @@ -19,6 +23,12 @@ app = Flask(__name__) app.config.update( SECRET_KEY=os.environ.get("SECRET_KEY", "change-this-secret-before-production"), DATABASE=DATABASE, + MAIL_HOST=os.environ.get("MAIL_HOST"), + MAIL_PORT=int(os.environ.get("MAIL_PORT", "587")), + MAIL_USERNAME=os.environ.get("MAIL_USERNAME"), + MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD"), + MAIL_FROM=os.environ.get("MAIL_FROM", "noreply@eternityproject.fi"), + PUBLIC_URL=os.environ.get("PUBLIC_URL", "http://localhost:8000").rstrip("/"), ) @@ -44,6 +54,7 @@ def init_db(): CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, is_approved INTEGER NOT NULL DEFAULT 0, @@ -63,6 +74,15 @@ def init_db(): updated_at TEXT NOT NULL, FOREIGN KEY (author_id) REFERENCES users(id) ); + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) + ); """ ) user_columns = {row["name"] for row in db.execute("PRAGMA table_info(users)")} @@ -71,10 +91,12 @@ def init_db(): "role": "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member'", "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", } for column, statement in migrations.items(): if column not in user_columns: db.execute(statement) + db.execute("CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique ON users(email)") # Preserve access for accounts created before approvals were introduced. db.execute("UPDATE users SET is_approved = 1 WHERE role = 'member' AND is_approved = 0") @@ -135,6 +157,40 @@ def post_from_request(post_id=None): return title, unique_slug(title, post_id), excerpt, body, category, now +def valid_email(email): + return re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) is not None + + +def create_reset_token(user_id): + token = token_urlsafe(32) + now = datetime.now(timezone.utc) + get_db().execute("UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL", (now.isoformat(), user_id)) + get_db().execute( + """INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_at) + VALUES (?, ?, ?, ?)""", + (user_id, sha256(token.encode("utf-8")).hexdigest(), (now + timedelta(hours=1)).isoformat(), now.isoformat()), + ) + get_db().commit() + return token + + +def send_reset_email(user): + if not app.config["MAIL_HOST"]: + raise RuntimeError("Email is not configured. Set MAIL_HOST and related SMTP settings.") + token = create_reset_token(user["id"]) + reset_url = f"{app.config['PUBLIC_URL']}{url_for('reset_password', token=token)}" + message = EmailMessage() + message["Subject"] = "Reset your Eternity Project password" + message["From"] = app.config["MAIL_FROM"] + message["To"] = user["email"] + message.set_content(f"Hello {user['username']},\n\nUse this link within one hour to choose a new password:\n{reset_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 @@ -168,22 +224,25 @@ def post(slug): def register(): if request.method == "POST": username = request.form.get("username", "").strip().lower() + email = request.form.get("email", "").strip().lower() password = request.form.get("password", "") if not re.fullmatch(r"[a-z0-9_-]{3,32}", username): flash("Use 3-32 lowercase letters, numbers, hyphens, or underscores.", "error") elif len(password) < 10: flash("Choose a password with at least 10 characters.", "error") + elif not valid_email(email): + flash("Enter a valid email address.", "error") else: try: get_db().execute( - "INSERT INTO users (username, password_hash, created_at, is_approved) VALUES (?, ?, ?, 0)", - (username, generate_password_hash(password), datetime.now(timezone.utc).isoformat()), + "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") return redirect(url_for("login")) except sqlite3.IntegrityError: - flash("That handle is already in use.", "error") + flash("That handle or email address is already in use.", "error") return render_template("auth.html", mode="register") @@ -206,6 +265,64 @@ def login(): return render_template("auth.html", mode="login") +@app.route("/password/forgot", methods=("GET", "POST")) +def forgot_password(): + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + user = get_db().execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone() + if user is not None: + try: + send_reset_email(user) + except (RuntimeError, OSError, smtplib.SMTPException): + app.logger.exception("Unable to send password reset email") + flash("If that address belongs to an account, a reset link has been sent.", "success") + return redirect(url_for("login")) + return render_template("password_form.html", mode="forgot") + + +@app.route("/password/reset/", methods=("GET", "POST")) +def reset_password(token): + now = datetime.now(timezone.utc).isoformat() + reset = get_db().execute( + """SELECT * FROM password_reset_tokens WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?""", + (sha256(token.encode("utf-8")).hexdigest(), now), + ).fetchone() + if reset is None: + flash("That password reset link is invalid or has expired.", "error") + return redirect(url_for("forgot_password")) + if request.method == "POST": + password = request.form.get("password", "") + if len(password) < 10: + flash("Choose a password with at least 10 characters.", "error") + else: + db = get_db() + db.execute("UPDATE users SET password_hash = ? WHERE id = ?", (generate_password_hash(password), reset["user_id"])) + db.execute("UPDATE password_reset_tokens SET used_at = ? WHERE id = ?", (now, reset["id"])) + db.commit() + flash("Password reset. Sign in with your new password.", "success") + return redirect(url_for("login")) + return render_template("password_form.html", mode="reset") + + +@app.route("/account/password", methods=("GET", "POST")) +@login_required +def change_password(): + if request.method == "POST": + user = get_db().execute("SELECT password_hash FROM users WHERE id = ?", (session["user_id"],)).fetchone() + current_password = request.form.get("current_password", "") + new_password = request.form.get("password", "") + if not check_password_hash(user["password_hash"], current_password): + flash("Your current password is incorrect.", "error") + elif len(new_password) < 10: + flash("Choose a new password with at least 10 characters.", "error") + else: + get_db().execute("UPDATE users SET password_hash = ? WHERE id = ?", (generate_password_hash(new_password), session["user_id"])) + get_db().commit() + flash("Password updated.", "success") + return redirect(url_for("index")) + return render_template("password_form.html", mode="change") + + @app.post("/logout") def logout(): session.clear() @@ -268,7 +385,7 @@ def mfa_verify(): @admin_required def admin_users(): users = get_db().execute( - "SELECT id, username, created_at, is_approved, mfa_enabled, role FROM users ORDER BY is_approved, created_at DESC" + "SELECT id, username, email, 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) @@ -282,6 +399,22 @@ def approve_user(user_id): return redirect(url_for("admin_users")) +@app.post("/admin/users//password-reset") +@admin_required +def admin_password_reset(user_id): + user = get_db().execute("SELECT * FROM users WHERE id = ? AND role = 'member'", (user_id,)).fetchone() + if user is None or not user["email"]: + flash("That member does not have an email address for password recovery.", "error") + else: + try: + send_reset_email(user) + flash(f"Password reset email sent to {user['email']}.", "success") + except (RuntimeError, OSError, smtplib.SMTPException): + app.logger.exception("Unable to send administrator password reset email") + flash("Password reset email could not be sent. Check SMTP settings.", "error") + return redirect(url_for("admin_users")) + + @app.route("/write", methods=("GET", "POST")) @login_required def write(): diff --git a/compose.yaml b/compose.yaml index 14f6860..f61d3f1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,6 +7,12 @@ services: environment: SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before deployment} DATABASE_PATH: /data/eternity.db + PUBLIC_URL: ${PUBLIC_URL:-http://localhost:8000} + MAIL_HOST: ${MAIL_HOST:-} + MAIL_PORT: ${MAIL_PORT:-587} + MAIL_USERNAME: ${MAIL_USERNAME:-} + MAIL_PASSWORD: ${MAIL_PASSWORD:-} + MAIL_FROM: ${MAIL_FROM:-noreply@eternityproject.fi} volumes: - eternity_data:/data restart: unless-stopped diff --git a/data/eternity.db b/data/eternity.db index e0ba0866641e8c599e1b62816a199e46371bd713..7a98f86cc1cbe59f475320418e5d234dd8932653 100644 GIT binary patch delta 465 zcmZoTz}T>WX@ayM8v_FaClJE`>qH%6SvCf}E)iZn69x{Bat4la{p`B@OF(<&;ZI#p3JMl=E=?`9&0{%FR!EyHs{70VslL~$L5dR&P*))KN#&cTku&i zMn!V4i5rSDR%YgdR0+&%jVupif*}BNQ;zD){?B oL^Z%BY(B$p60pcYVNw7K8zcW82L3;r1s&e=vjZcJnVEqT0Je~nKmY&$ delta 84 zcmZozz|?Snae}lUGXnzy8xX?)(?lI(ab^a+E)ib-9}KMA9~t;e_|Nh_;{M3HeY2oI hHTUL^+|Eo~&0>t~;^N|rZQPq1Ir
ACCOUNT QUEUE{{ users|length }} REGISTERED
diff --git a/templates/auth.html b/templates/auth.html index f2e5997..01e02e4 100644 --- a/templates/auth.html +++ b/templates/auth.html @@ -5,9 +5,10 @@
MEMBER ACCESS

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

Accounts let you publish and maintain your own engineering notes.

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

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

+

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

{% endblock %} diff --git a/templates/base.html b/templates/base.html index e2a87d9..03a9095 100644 --- a/templates/base.html +++ b/templates/base.html @@ -21,6 +21,7 @@ {% if current_user %} Write {% if current_user.role == 'admin' %}Accounts{% endif %} + Password {{ current_user.username }}
{% else %} diff --git a/templates/password_form.html b/templates/password_form.html new file mode 100644 index 0000000..b1c773a --- /dev/null +++ b/templates/password_form.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% block title %}Password | Eternity Project{% endblock %} +{% block content %} +
+
ACCOUNT SECURITY

{% if mode == 'forgot' %}Recover the
signal.{% elif mode == 'reset' %}Set a new
password.{% else %}Update your
password.{% endif %}

{% if mode == 'forgot' %}Enter the email address connected to your account. A secure reset link will arrive by email.{% else %}Choose a password with at least 10 characters.{% endif %}

+
+ {% if mode == 'forgot' %}{% endif %} + {% if mode == 'change' %}{% endif %} + {% if mode != 'forgot' %}{% endif %} + +
+
+{% endblock %} \ No newline at end of file