refactor: migrate admin auth from HTTP Basic to Flask-Login sessions

Replaces browser-cached Basic Auth credentials with proper server-side
session management. Logout now fully invalidates the session. Adds an
HTML login form at /admin/login, SECRET_KEY env var support, and updates
README with key generation instructions and role table.
This commit is contained in:
2026-03-10 11:41:16 -06:00
parent 94d6690e57
commit 2d4eac6583
6 changed files with 214 additions and 82 deletions
+38 -4
View File
@@ -98,9 +98,29 @@ Once deployed, open your browser and navigate to http://<your-server-ip>:8000 (o
## Admin Interface
A password-protected admin panel is available at `/admin`. It displays all guest entries in a paginated table and allows individual entries to be deleted.
A password-protected admin panel is available at `/admin`. It displays all guest entries in a paginated table and allows individual entries to be deleted. Authentication uses session cookies with an HTML login form — logging out fully invalidates the session so credentials are never cached by the browser.
Access requires `ADMIN_USER` and `ADMIN_PASSWORD` to be set in your `.env`. If either variable is missing, the admin interface will return a 503 error rather than allowing access with blank credentials.
Access requires `ADMIN_USER`, `ADMIN_PASSWORD`, and `SECRET_KEY` to be set in your `.env`. If either of the admin credentials are missing the interface returns 503. If `SECRET_KEY` is not set a default development key is used, which is insecure in production — always set your own.
### Generating a `SECRET_KEY`
Use Python to generate a cryptographically random key:
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
Paste the output as the value for `SECRET_KEY` in your `.env`.
### User Roles
The bootstrap superadmin (set via `ADMIN_USER` / `ADMIN_PASSWORD`) can manage additional users at `/admin/users`:
| Role | View entries | Delete entries | Manage users |
| ---------- | :----------: | :------------: | :----------: |
| superadmin | ✓ | ✓ | ✓ |
| admin | ✓ | ✓ | — |
| viewer | ✓ | — | — |
## API Access
@@ -112,14 +132,28 @@ Set the `API_KEY` variable in your `.env` and pass it in requests as the `X-API-
## Upgrading
When upgrading from a previous version, compare your `.env` against `example.env` to check for newly required variables. As of v2.1.0, the following variables are required if you want to use the admin interface:
When upgrading from a previous version, compare your `.env` against `example.env` to check for newly required variables.
As of **v2.1.0**, the following variables are required for the admin interface:
```env
ADMIN_USER=admin
ADMIN_PASSWORD=changeme
```
Replace the placeholder values with your own credentials before deploying.
As of **v2.3.0**, a `SECRET_KEY` is also required for session-based authentication:
```env
SECRET_KEY=your-random-secret-key-here
```
Generate one with:
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
Replace all placeholder values with your own before deploying.
## Additional Notes
+129 -67
View File
@@ -1,13 +1,16 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort, Response, g
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from email_validator import validate_email, EmailNotValidError
from werkzeug.security import generate_password_hash, check_password_hash
from functools import wraps
import sqlite3
import logging
import os
import re
import sqlite3
from email_validator import validate_email, EmailNotValidError
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_login import (
LoginManager, UserMixin, login_user, logout_user, login_required, current_user
)
from werkzeug.security import generate_password_hash, check_password_hash
# Set up logging
logging.basicConfig(level=logging.INFO)
@@ -15,8 +18,53 @@ logger = logging.getLogger(__name__)
app = Flask(__name__)
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
app.secret_key = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
limiter = Limiter(get_remote_address, app=app, default_limits=[])
login_manager = LoginManager(app)
login_manager.login_view = 'admin_login'
# ---------------------------------------------------------------------------
# User model
# ---------------------------------------------------------------------------
class User(UserMixin):
"""Lightweight user object stored in the session."""
def __init__(self, user_id, username, role):
# user_id format: 's:<username>' for superadmin, 'u:<db_id>' for DB users
self.id = user_id
self.username = username
self.role = role
@login_manager.user_loader
def load_user(user_id):
if user_id.startswith('s:'):
username = user_id[2:]
admin_user = os.environ.get('ADMIN_USER')
if admin_user and username == admin_user:
return User(user_id, username, 'superadmin')
return None
if user_id.startswith('u:'):
db_id = user_id[2:]
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
row = c.execute(
'SELECT id, username, role FROM users WHERE id = ?', (db_id,)
).fetchone()
conn.close()
if row:
return User(f'u:{row[0]}', row[1], row[2])
except sqlite3.Error as e:
logger.error("Database error in user_loader: %s", e)
return None
# ---------------------------------------------------------------------------
# Profanity filter
# ---------------------------------------------------------------------------
def load_banned_words():
banned_words = set()
file_path = os.path.join(os.path.dirname(__file__), 'en.txt')
@@ -53,6 +101,10 @@ def contains_banned_words(text):
return True
return False
# ---------------------------------------------------------------------------
# Database migrations
# ---------------------------------------------------------------------------
# Each entry is a list of SQL statements for that schema version.
# To add a column or index in the future, append a new list — never modify existing entries.
MIGRATIONS = [
@@ -121,6 +173,10 @@ def is_valid_email(email):
with app.app_context():
migrate_db()
# ---------------------------------------------------------------------------
# Public routes
# ---------------------------------------------------------------------------
@app.route('/', methods=['GET', 'POST'])
@limiter.limit("5 per minute", methods=["POST"])
def index():
@@ -188,65 +244,62 @@ def index():
logger.info("Rendering index with %d guests.", len(guests))
return render_template('index.html', error=error, guests=guests)
def _authenticate():
"""Returns (username, role) for the current request, or None if unauthenticated.
Role is 'superadmin', 'admin', or 'viewer'."""
auth = request.authorization
if not auth:
return None
# ---------------------------------------------------------------------------
# Admin auth routes
# ---------------------------------------------------------------------------
def _admin_configured():
return bool(os.environ.get('ADMIN_USER') and os.environ.get('ADMIN_PASSWORD'))
@app.route('/admin/login', methods=['GET', 'POST'])
def admin_login():
if not _admin_configured():
abort(503)
if current_user.is_authenticated:
return redirect(url_for('admin'))
error = None
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '').strip()
admin_user = os.environ.get('ADMIN_USER')
admin_password = os.environ.get('ADMIN_PASSWORD')
if admin_user and auth.username == admin_user and auth.password == admin_password:
return (auth.username, 'superadmin')
# Check superadmin first
if admin_user and username == admin_user and password == admin_password:
login_user(User(f's:{username}', username, 'superadmin'))
logger.info("Superadmin '%s' logged in.", username)
return redirect(request.args.get('next') or url_for('admin'))
# Check DB users
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
row = c.execute(
'SELECT password_hash, role FROM users WHERE username = ?', (auth.username,)
'SELECT id, password_hash, role FROM users WHERE username = ?', (username,)
).fetchone()
conn.close()
if row and check_password_hash(row[0], auth.password):
return (auth.username, row[1])
if row and check_password_hash(row[1], password):
login_user(User(f'u:{row[0]}', username, row[2]))
logger.info("User '%s' (role=%s) logged in.", username, row[2])
return redirect(request.args.get('next') or url_for('admin'))
except sqlite3.Error as e:
logger.error("Database error during authentication: %s", e)
return None
logger.error("Database error during login: %s", e)
error = 'Invalid username or password.'
logger.warning("Failed login attempt for username '%s'.", username)
return render_template('admin_login.html', error=error)
def _unauthorized():
return Response('Authentication required.', 401, {'WWW-Authenticate': 'Basic realm="Admin"'})
@app.route('/admin/logout')
def admin_logout():
logout_user()
return redirect(url_for('admin_login'))
def require_any_auth(f):
"""Allows superadmin, admin, and viewer roles."""
@wraps(f)
def decorated(*args, **kwargs):
if not os.environ.get('ADMIN_USER') or not os.environ.get('ADMIN_PASSWORD'):
logger.error("ADMIN_USER and ADMIN_PASSWORD must be set to enable the admin interface.")
abort(503)
user = _authenticate()
if user is None:
return _unauthorized()
g.current_user, g.current_role = user
return f(*args, **kwargs)
return decorated
def require_superadmin(f):
"""Allows only the bootstrap superadmin."""
@wraps(f)
def decorated(*args, **kwargs):
admin_user = os.environ.get('ADMIN_USER')
admin_password = os.environ.get('ADMIN_PASSWORD')
if not admin_user or not admin_password:
abort(503)
auth = request.authorization
if not auth or auth.username != admin_user or auth.password != admin_password:
return _unauthorized()
g.current_user = auth.username
g.current_role = 'superadmin'
return f(*args, **kwargs)
return decorated
# ---------------------------------------------------------------------------
# Admin routes
# ---------------------------------------------------------------------------
@app.route('/admin')
@require_any_auth
@login_required
def admin():
if not _admin_configured():
abort(503)
page = request.args.get('page', 1, type=int)
per_page = 25
offset = (page - 1) * per_page
@@ -266,12 +319,14 @@ def admin():
total = 0
total_pages = (total + per_page - 1) // per_page
return render_template('admin.html', guests=guests, page=page, total_pages=total_pages,
total=total, current_role=g.current_role)
total=total)
@app.route('/admin/delete/<int:entry_id>', methods=['POST'])
@require_any_auth
@login_required
def admin_delete(entry_id):
if g.current_role == 'viewer':
if not _admin_configured():
abort(503)
if current_user.role == 'viewer':
abort(403)
try:
conn = sqlite3.connect(DATABASE)
@@ -284,18 +339,13 @@ def admin_delete(entry_id):
logger.error("Database error deleting guest %d: %s", entry_id, e)
return redirect(url_for('admin', page=request.args.get('page', 1)))
@app.route('/admin/logout')
def admin_logout():
return Response(
'<p style="font-family:sans-serif">You have been logged out. '
'<a href="/admin">Log in again</a></p>',
401,
{'WWW-Authenticate': 'Basic realm="Admin"', 'Content-Type': 'text/html'}
)
@app.route('/admin/users')
@require_superadmin
@login_required
def admin_users():
if not _admin_configured():
abort(503)
if current_user.role != 'superadmin':
abort(403)
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
@@ -307,8 +357,12 @@ def admin_users():
return render_template('admin_users.html', users=users)
@app.route('/admin/users/add', methods=['POST'])
@require_superadmin
@login_required
def admin_users_add():
if not _admin_configured():
abort(503)
if current_user.role != 'superadmin':
abort(403)
username = request.form.get('username', '').strip()
password = request.form.get('password', '').strip()
role = request.form.get('role', '').strip()
@@ -331,8 +385,12 @@ def admin_users_add():
return redirect(url_for('admin_users'))
@app.route('/admin/users/delete/<int:user_id>', methods=['POST'])
@require_superadmin
@login_required
def admin_users_delete(user_id):
if not _admin_configured():
abort(503)
if current_user.role != 'superadmin':
abort(403)
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
@@ -344,6 +402,10 @@ def admin_users_delete(user_id):
logger.error("Database error deleting user %d: %s", user_id, e)
return redirect(url_for('admin_users'))
# ---------------------------------------------------------------------------
# API
# ---------------------------------------------------------------------------
@app.route('/api/guests', methods=['GET'])
def api_guests():
api_key = request.headers.get('X-API-Key')
+1
View File
@@ -12,3 +12,4 @@ SITE_TITLE="The Montana Dinosaur Center Visitor Log"
LOGO_URL="/static/images/logo.png"
ADMIN_USER=admin
ADMIN_PASSWORD=changeme
SECRET_KEY=change-this-to-a-random-secret-key
+1
View File
@@ -1,5 +1,6 @@
Flask>=3.1.3
Werkzeug>=3.0.6
Flask-Limiter>=3.0
Flask-Login>=0.6
email-validator>=2.0
gunicorn
+3 -3
View File
@@ -11,8 +11,8 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">Guestbook Admin</h1>
<div class="d-flex align-items-center gap-3">
<span class="text-muted">{{ total }} total entries</span>
{% if current_role == 'superadmin' %}
<span class="text-muted">{{ current_user.username }} &middot; {{ total }} entries</span>
{% if current_user.role == 'superadmin' %}
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-secondary btn-sm">Manage Users</a>
{% endif %}
<a href="{{ url_for('admin_logout') }}" class="btn btn-outline-danger btn-sm">Logout</a>
@@ -44,7 +44,7 @@
<td>{{ 'Yes' if g[6] else 'No' }}</td>
<td class="text-nowrap">{{ g[7] }}</td>
<td>
{% if current_role != 'viewer' %}
{% if current_user.role != 'viewer' %}
<form method="POST" action="{{ url_for('admin_delete', entry_id=g[0]) }}?page={{ page }}"
onsubmit="return confirm('Delete entry for {{ g[1] }} {{ g[2] }}?')">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Guestbook Admin — Login</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body class="bg-light">
<div class="container py-5" style="max-width: 400px;">
<h1 class="h4 mb-4 text-center">Admin Login</h1>
<div class="card">
<div class="card-body">
{% if error %}
<div class="alert alert-danger py-2">{{ error }}</div>
{% endif %}
<form method="POST" action="{{ url_for('admin_login', next=request.args.get('next', '')) }}">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" id="username" name="username" class="form-control"
autocomplete="username" required autofocus />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" id="password" name="password" class="form-control"
autocomplete="current-password" required />
</div>
<button type="submit" class="btn btn-primary w-100">Log In</button>
</form>
</div>
</div>
</div>
</body>
</html>