Add sfsr protection and admin pwd change script for powershell
This commit is contained in:
@@ -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=<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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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`.
|
||||
Binary file not shown.
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
<span>{{ user.created_at[:10] }}</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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<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>
|
||||
<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>
|
||||
{% 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>
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
{% if current_user.role == 'admin' %}<a href="{{ url_for('admin_users') }}">Accounts</a>{% endif %}
|
||||
<a href="{{ url_for('change_password') }}">Password</a>
|
||||
<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 %}
|
||||
<a href="{{ url_for('login') }}">Sign in</a>
|
||||
<a class="button compact" href="{{ url_for('register') }}">Create account</a>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% 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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<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>
|
||||
<button class="button" type="submit">Verify account <span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<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>
|
||||
<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 == '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 %}
|
||||
|
||||
Reference in New Issue
Block a user