Add sfsr protection and admin pwd change script for powershell

This commit is contained in:
2026-08-29 20:07:00 +03:00
parent 8a2cbe5b8b
commit 4512f78033
13 changed files with 118 additions and 5 deletions
+31
View File
@@ -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. 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 ## 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. 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. 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=<address>` 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 ## 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. 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.
+3 -3
View File
@@ -4,8 +4,8 @@
- [x] Local accounts, administrator approval, and TOTP MFA. - [x] Local accounts, administrator approval, and TOTP MFA.
- [x] QR-based MFA enrollment for iPhone and Android authenticators. - [x] QR-based MFA enrollment for iPhone and Android authenticators.
- [x] Email addresses, member password changes, and expiring password-reset links. - [x] Email addresses, member password changes, and expiring password-reset links.
- [ ] Configure production SMTP credentials and verify outgoing email delivery. - [ ] 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.
- [ ] Change the initial `admin` password and enroll its authenticator. - [ ] Run `./scripts/rotate-admin.ps1`, then sign in as `admin` and scan the new MFA QR code to complete authenticator enrollment.
- [ ] Add CSRF protection to all state-changing forms. - [x] Add CSRF protection to all state-changing forms.
- [ ] Add automated database backups and test restoration. - [ ] Add automated database backups and test restoration.
- [ ] Configure TLS reverse proxy and production domain for `eternityproject.fi`. - [ ] Configure TLS reverse proxy and production domain for `eternityproject.fi`.
Binary file not shown.
+2
View File
@@ -12,6 +12,7 @@ from secrets import token_urlsafe
from hashlib import sha256 from hashlib import sha256
from flask import Flask, abort, flash, g, redirect, render_template, request, session, url_for from flask import Flask, abort, flash, g, redirect, render_template, request, session, url_for
from flask_wtf.csrf import CSRFProtect
import pyotp import pyotp
import qrcode import qrcode
from werkzeug.security import check_password_hash, generate_password_hash 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"), MAIL_FROM=os.environ.get("MAIL_FROM", "noreply@eternityproject.fi"),
PUBLIC_URL=os.environ.get("PUBLIC_URL", "http://localhost:8000").rstrip("/"), PUBLIC_URL=os.environ.get("PUBLIC_URL", "http://localhost:8000").rstrip("/"),
) )
csrf = CSRFProtect(app)
def get_db(): def get_db():
+1
View File
@@ -2,3 +2,4 @@ Flask==3.1.1
gunicorn==23.0.0 gunicorn==23.0.0
pyotp==2.9.0 pyotp==2.9.0
qrcode[pil]==8.2 qrcode[pil]==8.2
Flask-WTF==1.2.2
+37
View File
@@ -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)
}
}
+38
View File
@@ -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()
}
+1 -1
View File
@@ -10,7 +10,7 @@
<strong>{{ user.username }}{% if user.role == 'admin' %} <small>ADMIN</small>{% endif %}<small class="email">{{ user.email or 'NO EMAIL' }}</small></strong> <strong>{{ user.username }}{% if user.role == 'admin' %} <small>ADMIN</small>{% endif %}<small class="email">{{ user.email or 'NO EMAIL' }}</small></strong>
<span>{{ user.created_at[:10] }}</span> <span>{{ user.created_at[:10] }}</span>
<span>{{ 'ENABLED' if user.mfa_enabled else 'PENDING' }}</span> <span>{{ 'ENABLED' if user.mfa_enabled else 'PENDING' }}</span>
<span>{% if user.is_approved and user.role == 'member' %}<form method="post" action="{{ url_for('admin_password_reset', user_id=user.id) }}"><button class="button compact" type="submit">Reset password</button></form>{% elif user.is_approved %}APPROVED{% else %}<form method="post" action="{{ url_for('approve_user', user_id=user.id) }}"><button class="button compact" type="submit">Approve</button></form>{% endif %}</span> <span>{% if user.is_approved and user.role == 'member' %}<form method="post" action="{{ url_for('admin_password_reset', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Reset password</button></form>{% elif user.is_approved %}APPROVED{% else %}<form method="post" action="{{ url_for('approve_user', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Approve</button></form>{% endif %}</span>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
+1
View File
@@ -4,6 +4,7 @@
<section class="auth-layout"> <section class="auth-layout">
<div class="auth-copy"><span class="signal-label">MEMBER ACCESS</span><h1>{{ 'Join the circuit.' if mode == 'register' else 'Resume the signal.' }}</h1><p>Accounts let you publish and maintain your own engineering notes.</p></div> <div class="auth-copy"><span class="signal-label">MEMBER ACCESS</span><h1>{{ 'Join the circuit.' if mode == 'register' else 'Resume the signal.' }}</h1><p>Accounts let you publish and maintain your own engineering notes.</p></div>
<form class="auth-form" method="post"> <form class="auth-form" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Handle<input name="username" autocomplete="username" required pattern="[a-z0-9_-]{3,32}" placeholder="your-handle"></label> <label>Handle<input name="username" autocomplete="username" required pattern="[a-z0-9_-]{3,32}" placeholder="your-handle"></label>
{% if mode == 'register' %}<label>Email<input name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>{% endif %} {% if mode == 'register' %}<label>Email<input name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>{% endif %}
<label>Password<input name="password" type="password" autocomplete="{{ 'new-password' if mode == 'register' else 'current-password' }}" required {% if mode == 'register' %}minlength="10" placeholder="10+ characters"{% else %}placeholder="Your password"{% endif %}></label> <label>Password<input name="password" type="password" autocomplete="{{ 'new-password' if mode == 'register' else 'current-password' }}" required {% if mode == 'register' %}minlength="10" placeholder="10+ characters"{% else %}placeholder="Your password"{% endif %}></label>
+1 -1
View File
@@ -23,7 +23,7 @@
{% if current_user.role == 'admin' %}<a href="{{ url_for('admin_users') }}">Accounts</a>{% endif %} {% if current_user.role == 'admin' %}<a href="{{ url_for('admin_users') }}">Accounts</a>{% endif %}
<a href="{{ url_for('change_password') }}">Password</a> <a href="{{ url_for('change_password') }}">Password</a>
<span class="user-chip">{{ current_user.username }}</span> <span class="user-chip">{{ current_user.username }}</span>
<form action="{{ url_for('logout') }}" method="post"><button class="text-button" type="submit">Log out</button></form> <form action="{{ url_for('logout') }}" method="post"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button" type="submit">Log out</button></form>
{% else %} {% else %}
<a href="{{ url_for('login') }}">Sign in</a> <a href="{{ url_for('login') }}">Sign in</a>
<a class="button compact" href="{{ url_for('register') }}">Create account</a> <a class="button compact" href="{{ url_for('register') }}">Create account</a>
+1
View File
@@ -3,6 +3,7 @@
{% block content %} {% block content %}
<section class="editor-wrap"><div class="section-head"><span>{{ 'EDIT TRANSMISSION' if post else 'NEW TRANSMISSION' }}</span><span>AUTHOR / {{ current_user.username }}</span></div> <section class="editor-wrap"><div class="section-head"><span>{{ 'EDIT TRANSMISSION' if post else 'NEW TRANSMISSION' }}</span><span>AUTHOR / {{ current_user.username }}</span></div>
<form class="editor-form" method="post"> <form class="editor-form" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Title<input name="title" required value="{{ post.title if post else '' }}" placeholder="A clear, useful heading"></label> <label>Title<input name="title" required value="{{ post.title if post else '' }}" placeholder="A clear, useful heading"></label>
<div class="two-col"><label>Category<input name="category" value="{{ post.category if post else 'Engineering' }}" placeholder="Engineering"></label><label>Summary<input name="excerpt" required value="{{ post.excerpt if post else '' }}" placeholder="The one-paragraph signal"></label></div> <div class="two-col"><label>Category<input name="category" value="{{ post.category if post else 'Engineering' }}" placeholder="Engineering"></label><label>Summary<input name="excerpt" required value="{{ post.excerpt if post else '' }}" placeholder="The one-paragraph signal"></label></div>
<label>Article<textarea name="body" required rows="16" placeholder="Write in plain text. Paragraph breaks are preserved.">{{ post.body if post else '' }}</textarea></label> <label>Article<textarea name="body" required rows="16" placeholder="Write in plain text. Paragraph breaks are preserved.">{{ post.body if post else '' }}</textarea></label>
+1
View File
@@ -15,6 +15,7 @@
{% endif %} {% endif %}
</div> </div>
<form class="auth-form" method="post"> <form class="auth-form" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Authenticator code<input name="code" inputmode="numeric" autocomplete="one-time-code" required pattern="[0-9]{6}" maxlength="6" placeholder="000000"></label> <label>Authenticator code<input name="code" inputmode="numeric" autocomplete="one-time-code" required pattern="[0-9]{6}" maxlength="6" placeholder="000000"></label>
<button class="button" type="submit">Verify account <span aria-hidden="true">→</span></button> <button class="button" type="submit">Verify account <span aria-hidden="true">→</span></button>
</form> </form>
+1
View File
@@ -4,6 +4,7 @@
<section class="auth-layout"> <section class="auth-layout">
<div class="auth-copy"><span class="signal-label">ACCOUNT SECURITY</span><h1>{% if mode == 'forgot' %}Recover the<br>signal.{% elif mode == 'reset' %}Set a new<br>password.{% else %}Update your<br>password.{% endif %}</h1><p>{% 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 %}</p></div> <div class="auth-copy"><span class="signal-label">ACCOUNT SECURITY</span><h1>{% if mode == 'forgot' %}Recover the<br>signal.{% elif mode == 'reset' %}Set a new<br>password.{% else %}Update your<br>password.{% endif %}</h1><p>{% 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 %}</p></div>
<form class="auth-form" method="post"> <form class="auth-form" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if mode == 'forgot' %}<label>Email<input name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>{% endif %} {% if mode == 'forgot' %}<label>Email<input name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>{% endif %}
{% if mode == 'change' %}<label>Current password<input name="current_password" type="password" autocomplete="current-password" required></label>{% endif %} {% if mode == 'change' %}<label>Current password<input name="current_password" type="password" autocomplete="current-password" required></label>{% endif %}
{% if mode != 'forgot' %}<label>New password<input name="password" type="password" autocomplete="new-password" required minlength="10" placeholder="10+ characters"></label>{% endif %} {% if mode != 'forgot' %}<label>New password<input name="password" type="password" autocomplete="new-password" required minlength="10" placeholder="10+ characters"></label>{% endif %}