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 021daca..51feb7d 100644 Binary files a/__pycache__/app.cpython-314.pyc and b/__pycache__/app.cpython-314.pyc differ 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 e0ba086..7a98f86 100644 Binary files a/data/eternity.db and b/data/eternity.db differ diff --git a/static/css/site.css b/static/css/site.css index d40b57b..1fe5b9e 100644 --- a/static/css/site.css +++ b/static/css/site.css @@ -14,5 +14,5 @@ nav { display:flex; align-items:center; gap:22px; font-family:"DM Mono", monospa .auth-layout { min-height:calc(100vh - 160px); padding:70px max(10vw, 24px); display:grid; grid-template-columns:1fr minmax(280px, 390px); gap:10vw; align-items:center; }.auth-copy h1 { font-size:clamp(45px, 6vw, 82px); line-height:.9; margin:15px 0; }.auth-copy p { max-width:380px; font-size:18px; line-height:1.5; color:var(--muted); }.auth-form, .editor-form { display:grid; gap:19px; }.auth-form { padding:27px; border:1px solid var(--ink); background:rgba(241,241,233,.8); }.auth-form label, .editor-form label { display:grid; gap:7px; font:11px "DM Mono", monospace; }input, textarea { width:100%; resize:vertical; background:#fbfbf6; border:1px solid var(--ink); border-radius:0; padding:12px; color:var(--ink); font:15px "Space Grotesk", sans-serif; }input:focus, textarea:focus { outline:3px solid var(--acid); outline-offset:1px; }.form-switch { font-size:13px; color:var(--muted); }.form-switch a { color:var(--ink); text-decoration:underline; }.editor-form { max-width:900px; }.two-col { display:grid; grid-template-columns:1fr 2fr; gap:19px; } .article { max-width:850px; margin:0 auto; padding:80px 24px 100px; }.article h1 { font-size:clamp(48px, 7vw, 90px); line-height:.93; margin:23px 0; }.article-lead { border-left:4px solid var(--orange); padding-left:18px; max-width:720px; font-size:23px; line-height:1.35; }.byline { padding:20px 0; border-top:1px solid var(--ink); border-bottom:1px solid var(--ink); margin:35px 0; }.article-body { max-width:680px; white-space:pre-wrap; font-size:18px; line-height:1.7; margin-bottom:35px; } .flash { margin:16px max(5vw, 24px) 0; padding:11px 14px; font:12px "DM Mono", monospace; border:1px solid var(--ink); }.flash.error { border-color:var(--orange); background:#ffe3d9; }.flash.success { background:var(--acid); }footer { border-top:1px solid var(--ink); padding:23px max(5vw, 24px); display:flex; justify-content:space-between; font:10px "DM Mono", monospace; color:var(--muted); } -.mfa-qr { display:block; width:200px; max-width:100%; margin-top:28px; border:8px solid #fff; image-rendering:pixelated; }.mfa-manual { margin-top:18px; color:var(--muted); font:12px "DM Mono", monospace; }.mfa-manual summary { cursor:pointer; }.mfa-secret { display:block; width:max-content; max-width:100%; overflow-wrap:anywhere; margin-top:10px; padding:14px; border:1px solid var(--ink); background:var(--acid); color:var(--ink); font:14px "DM Mono", monospace; }.account-table { border:1px solid var(--ink); }.account-row { display:grid; grid-template-columns:2fr 1.5fr 1fr 1fr; gap:16px; align-items:center; min-height:64px; padding:12px 16px; border-bottom:1px solid var(--ink); font:13px "DM Mono", monospace; }.account-row:last-child { border-bottom:0; }.account-head { min-height:auto; padding:10px 16px; background:var(--ink); color:var(--paper); font-size:10px; }.account-row small { color:var(--orange); font:10px "DM Mono", monospace; } +.mfa-qr { display:block; width:200px; max-width:100%; margin-top:28px; border:8px solid #fff; image-rendering:pixelated; }.mfa-manual { margin-top:18px; color:var(--muted); font:12px "DM Mono", monospace; }.mfa-manual summary { cursor:pointer; }.mfa-secret { display:block; width:max-content; max-width:100%; overflow-wrap:anywhere; margin-top:10px; padding:14px; border:1px solid var(--ink); background:var(--acid); color:var(--ink); font:14px "DM Mono", monospace; }.account-table { border:1px solid var(--ink); }.account-row { display:grid; grid-template-columns:2fr 1.5fr 1fr 1fr; gap:16px; align-items:center; min-height:64px; padding:12px 16px; border-bottom:1px solid var(--ink); font:13px "DM Mono", monospace; }.account-row:last-child { border-bottom:0; }.account-head { min-height:auto; padding:10px 16px; background:var(--ink); color:var(--paper); font-size:10px; }.account-row small { color:var(--orange); font:10px "DM Mono", monospace; }.account-row .email { display:block; margin-top:5px; color:var(--muted); overflow-wrap:anywhere; } @media (max-width:720px) { .site-header { align-items:flex-start; }.site-header nav { justify-content:flex-end; flex-wrap:wrap; gap:10px 15px; }.hero { min-height:560px; }.scope { width:220px; height:220px; right:-45px; }.post-grid, .auth-layout, .two-col { grid-template-columns:1fr; }.auth-layout { gap:40px; padding:60px 24px; }footer { flex-wrap:wrap; gap:8px 16px; }.user-chip { display:none; }.account-row { grid-template-columns:1fr 1fr; }.account-head { display:none; } } diff --git a/templates/admin_users.html b/templates/admin_users.html index ee791cd..a384a91 100644 --- a/templates/admin_users.html +++ b/templates/admin_users.html @@ -4,13 +4,13 @@
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