diff --git a/README.md b/README.md index a98a32a..60a09c8 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,43 @@ 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. +### Secure the initial administrator + +With the Docker service running, rotate the initial password locally. The command prompts for a new password without writing it to a file or command history. It also clears the old MFA binding so that the next administrator sign-in requires a new QR-based authenticator enrollment. + +```powershell +.\scripts\rotate-admin.ps1 +``` + +After it prints `ADMIN_PASSWORD_ROTATED_MFA_RESET`, sign in as `admin` with the new password, scan the fresh QR code with an authenticator application, and enter its six-digit code to complete enrollment. Store the new password in a password manager. + ## 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**. +All browser POST forms are protected by server-validated CSRF tokens. + 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. +### Configure SMTP delivery + +1. Copy `.env.example` to `.env` if it does not exist. +2. Set `PUBLIC_URL=https://eternityproject.fi`. +3. Enter the SMTP host, port, username, password or provider app password, and verified sender address. Use port `587` for STARTTLS. +4. Rebuild the service so Compose applies the values: + +```sh +docker compose up --build -d +``` + +5. Send a delivery test to an inbox you control: + +```powershell +.\scripts\test-smtp.ps1 -To you@example.com +``` + +The command prints `SMTP_TEST_SENT_TO=
` only after the SMTP server accepts the message. Confirm the message arrives, then use **Forgot your password?** in the application to verify a real reset email and link. + ## 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 index 4ee4937..85ab7b6 100644 --- a/TODO.md +++ b/TODO.md @@ -4,8 +4,8 @@ - [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. +- [ ] Configure production SMTP credentials and verify outgoing email delivery. See the SMTP delivery steps in [README.md](README.md); complete after the test email is received. +- [ ] Run `./scripts/rotate-admin.ps1`, then sign in as `admin` and scan the new MFA QR code to complete authenticator enrollment. +- [x] 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 51feb7d..916674d 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 9229910..ebbddd7 100644 --- a/app.py +++ b/app.py @@ -12,6 +12,7 @@ from secrets import token_urlsafe from hashlib import sha256 from flask import Flask, abort, flash, g, redirect, render_template, request, session, url_for +from flask_wtf.csrf import CSRFProtect import pyotp import qrcode from werkzeug.security import check_password_hash, generate_password_hash @@ -30,6 +31,7 @@ app.config.update( MAIL_FROM=os.environ.get("MAIL_FROM", "noreply@eternityproject.fi"), PUBLIC_URL=os.environ.get("PUBLIC_URL", "http://localhost:8000").rstrip("/"), ) +csrf = CSRFProtect(app) def get_db(): diff --git a/requirements.txt b/requirements.txt index d241df2..9a53685 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ Flask==3.1.1 gunicorn==23.0.0 pyotp==2.9.0 qrcode[pil]==8.2 +Flask-WTF==1.2.2 diff --git a/scripts/rotate-admin.ps1 b/scripts/rotate-admin.ps1 new file mode 100644 index 0000000..f23621f --- /dev/null +++ b/scripts/rotate-admin.ps1 @@ -0,0 +1,37 @@ +$securePassword = Read-Host "Enter new admin password (10+ characters)" -AsSecureString +$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword) + +try { + $password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) + if ($password.Length -lt 10) { + throw "The new password must have at least 10 characters." + } + + $password | docker compose exec -T eternityproject python -c @' +import sys +from werkzeug.security import generate_password_hash +from app import app, get_db + +password = sys.stdin.readline().rstrip("\r\n") +if len(password) < 10: + raise SystemExit("The new password must have at least 10 characters.") +with app.app_context(): + database = get_db() + result = database.execute( + "UPDATE users SET password_hash = ?, mfa_secret = NULL, mfa_enabled = 0 WHERE username = 'admin' AND role = 'admin'", + (generate_password_hash(password),), + ) + database.commit() + if result.rowcount != 1: + raise SystemExit("The administrator account was not found.") +print("ADMIN_PASSWORD_ROTATED_MFA_RESET") +'@ + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } +} +finally { + if ($pointer -ne [IntPtr]::Zero) { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) + } +} diff --git a/scripts/test-smtp.ps1 b/scripts/test-smtp.ps1 new file mode 100644 index 0000000..0e9bce1 --- /dev/null +++ b/scripts/test-smtp.ps1 @@ -0,0 +1,38 @@ +param( + [Parameter(Mandatory = $true)] + [string]$To +) + +$envFile = Join-Path $PSScriptRoot "..\.env" +if (-not (Test-Path $envFile)) { + throw "Missing .env. Copy .env.example to .env and enter the SMTP settings." +} + +Get-Content $envFile | ForEach-Object { + if ($_ -match "^\s*([^#=\s]+)\s*=\s*(.*)\s*$") { + [Environment]::SetEnvironmentVariable($matches[1], $matches[2], "Process") + } +} + +$required = "MAIL_HOST", "MAIL_PORT", "MAIL_USERNAME", "MAIL_PASSWORD", "MAIL_FROM" +$missing = $required | Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_, "Process")) } +if ($missing) { + throw "Missing required SMTP settings: $($missing -join ', ')" +} + +$message = [System.Net.Mail.MailMessage]::new($env:MAIL_FROM, $To) +$message.Subject = "Eternity Project SMTP verification" +$message.Body = "SMTP delivery verification completed at $(Get-Date -Format o)." + +$client = [System.Net.Mail.SmtpClient]::new($env:MAIL_HOST, [int]$env:MAIL_PORT) +$client.EnableSsl = $true +$client.Credentials = [System.Net.NetworkCredential]::new($env:MAIL_USERNAME, $env:MAIL_PASSWORD) + +try { + $client.Send($message) + Write-Output "SMTP_TEST_SENT_TO=$To" +} +finally { + $message.Dispose() + $client.Dispose() +} diff --git a/templates/admin_users.html b/templates/admin_users.html index a384a91..fe43551 100644 --- a/templates/admin_users.html +++ b/templates/admin_users.html @@ -10,7 +10,7 @@ {{ 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 %}
+ {% if user.is_approved and user.role == 'member' %}
{% elif user.is_approved %}APPROVED{% else %}
{% endif %}
{% endfor %} diff --git a/templates/auth.html b/templates/auth.html index 01e02e4..e1514c7 100644 --- a/templates/auth.html +++ b/templates/auth.html @@ -4,6 +4,7 @@
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 %} diff --git a/templates/base.html b/templates/base.html index 03a9095..353fe40 100644 --- a/templates/base.html +++ b/templates/base.html @@ -23,7 +23,7 @@ {% if current_user.role == 'admin' %}Accounts{% endif %} Password {{ current_user.username }} -
+
{% else %} Sign in Create account diff --git a/templates/editor.html b/templates/editor.html index 5748778..98aa1af 100644 --- a/templates/editor.html +++ b/templates/editor.html @@ -3,6 +3,7 @@ {% block content %}
{{ 'EDIT TRANSMISSION' if post else 'NEW TRANSMISSION' }}AUTHOR / {{ current_user.username }}
+
diff --git a/templates/mfa.html b/templates/mfa.html index 798e156..61d9cd4 100644 --- a/templates/mfa.html +++ b/templates/mfa.html @@ -15,6 +15,7 @@ {% endif %} +
diff --git a/templates/password_form.html b/templates/password_form.html index b1c773a..09d228b 100644 --- a/templates/password_form.html +++ b/templates/password_form.html @@ -4,6 +4,7 @@
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 %}