diff --git a/README.md b/README.md index b526050..82bd26a 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Code is displayed as literal text and can be copied from the article view. Image Posts are public by default. Authors can select **Members only** while writing or editing to hide a post from visitors who are not signed in; MFA-verified members can still read these posts. +Administrators can freeze member accounts from **Accounts**. Frozen members cannot sign in and are signed out on their next protected request; administrators can unfreeze them later. Administrators can also permanently delete a member account. Deleting an account also permanently removes its posts, uploaded images, and outstanding reset or verification tokens. + ## Accounts and MFA 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. diff --git a/TODO.md b/TODO.md index 715b4ea..5f011b3 100644 --- a/TODO.md +++ b/TODO.md @@ -15,4 +15,5 @@ - [x] Allow users to change their email addresses. - [x] Add user email verification for registration and email-address changes. - [x] Add user real names and use them as the published-by name. -- [x] Add ability to hide posts from unregistered users. \ No newline at end of file +- [x] Add ability to hide posts from unregistered users. +- [x] Add administrator controls to freeze and delete member accounts. \ No newline at end of file diff --git a/app.py b/app.py index db183cd..b07b941 100644 --- a/app.py +++ b/app.py @@ -67,7 +67,8 @@ def init_db(): is_approved INTEGER NOT NULL DEFAULT 0, role TEXT NOT NULL DEFAULT 'member', mfa_secret TEXT, - mfa_enabled INTEGER NOT NULL DEFAULT 0 + mfa_enabled INTEGER NOT NULL DEFAULT 0, + is_frozen INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -123,6 +124,7 @@ def init_db(): "email": "ALTER TABLE users ADD COLUMN email TEXT", "email_verified": "ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0", "real_name": "ALTER TABLE users ADD COLUMN real_name TEXT", + "is_frozen": "ALTER TABLE users ADD COLUMN is_frozen INTEGER NOT NULL DEFAULT 0", } post_columns = {row["name"] for row in db.execute("PRAGMA table_info(posts)")} for column, statement in migrations.items(): @@ -154,6 +156,11 @@ def login_required(view): if "user_id" not in session or not session.get("mfa_verified"): flash("Sign in to continue.", "notice") return redirect(url_for("login", next=request.path)) + user = get_db().execute("SELECT is_frozen FROM users WHERE id = ?", (session["user_id"],)).fetchone() + if user is None or user["is_frozen"]: + session.clear() + flash("This account has been frozen by an administrator.", "error") + return redirect(url_for("login")) return view(*args, **kwargs) return wrapped_view @@ -413,6 +420,8 @@ def login(): user = get_db().execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone() if user is None or not check_password_hash(user["password_hash"], request.form.get("password", "")): flash("Invalid handle or password.", "error") + elif user["is_frozen"]: + flash("This account has been frozen by an administrator.", "error") elif not user["is_approved"]: flash("Your account is awaiting administrator approval.", "notice") elif user["email"] and not user["email_verified"]: @@ -637,7 +646,7 @@ def mfa_verify(): @admin_required def admin_users(): users = get_db().execute( - "SELECT id, username, real_name, email, email_verified, created_at, is_approved, mfa_enabled, role FROM users ORDER BY is_approved, created_at DESC" + "SELECT id, username, real_name, email, email_verified, created_at, is_approved, mfa_enabled, is_frozen, role FROM users ORDER BY is_frozen DESC, is_approved, created_at DESC" ).fetchall() return render_template("admin_users.html", users=users) @@ -651,6 +660,48 @@ def approve_user(user_id): return redirect(url_for("admin_users")) +@app.post("/admin/users//freeze") +@admin_required +def freeze_user(user_id): + cursor = get_db().execute("UPDATE users SET is_frozen = 1 WHERE id = ? AND role = 'member' AND is_frozen = 0", (user_id,)) + get_db().commit() + flash("Account frozen." if cursor.rowcount else "No active member account was changed.", "success") + return redirect(url_for("admin_users")) + + +@app.post("/admin/users//unfreeze") +@admin_required +def unfreeze_user(user_id): + cursor = get_db().execute("UPDATE users SET is_frozen = 0 WHERE id = ? AND role = 'member' AND is_frozen = 1", (user_id,)) + get_db().commit() + flash("Account unfrozen." if cursor.rowcount else "No frozen member account was changed.", "success") + return redirect(url_for("admin_users")) + + +@app.post("/admin/users//delete") +@admin_required +def delete_user(user_id): + db = get_db() + user = db.execute("SELECT id, username FROM users WHERE id = ? AND role = 'member'", (user_id,)).fetchone() + if user is None: + abort(404) + image_rows = db.execute( + """SELECT image_filename AS filename FROM posts WHERE author_id = ? AND image_filename IS NOT NULL + UNION SELECT post_images.filename FROM post_images JOIN posts ON posts.id = post_images.post_id WHERE posts.author_id = ?""", + (user_id, user_id), + ).fetchall() + db.execute("DELETE FROM post_images WHERE post_id IN (SELECT id FROM posts WHERE author_id = ?)", (user_id,)) + db.execute("DELETE FROM posts WHERE author_id = ?", (user_id,)) + db.execute("DELETE FROM password_reset_tokens WHERE user_id = ?", (user_id,)) + db.execute("DELETE FROM email_verification_tokens WHERE user_id = ?", (user_id,)) + db.execute("DELETE FROM users WHERE id = ?", (user_id,)) + db.commit() + for image in image_rows: + (app.config["UPLOAD_FOLDER"] / image["filename"]).unlink(missing_ok=True) + flash(f"Account {user['username']} and its posts were permanently deleted.", "success") + return redirect(url_for("admin_users")) + + @app.post("/admin/users//password-reset") @admin_required def admin_password_reset(user_id): diff --git a/static/css/site.css b/static/css/site.css index ba9a07b..b3dfe81 100644 --- a/static/css/site.css +++ b/static/css/site.css @@ -18,4 +18,5 @@ nav { display:flex; align-items:center; gap:22px; font-family:"DM Mono", monospa .code-block { margin:28px 0; border:1px solid var(--ink); background:var(--ink); }.code-block-head { display:flex; align-items:center; justify-content:space-between; min-height:38px; padding:7px 10px 7px 14px; color:var(--paper); font:11px "DM Mono", monospace; letter-spacing:.7px; text-transform:uppercase; }.copy-code { border:1px solid var(--paper); background:transparent; color:var(--paper); padding:5px 8px; font:11px "DM Mono", monospace; cursor:pointer; }.copy-code:hover { border-color:var(--acid); background:var(--acid); color:var(--ink); }.code-block pre { margin:0; padding:18px; overflow:auto; background:#fbfbf6; color:var(--ink); font:14px/1.55 "DM Mono", monospace; white-space:pre; }.code-block code { font:inherit; } .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; }.account-row .email { display:block; margin-top:5px; color:var(--muted); overflow-wrap:anywhere; } +.account-actions { display:flex; flex-wrap:wrap; align-items:center; gap:6px; }.account-actions form { margin:0; }.account-actions strong { color:var(--orange); font:10px "DM Mono", monospace; }.delete-account { border-color:var(--orange); background:#ffe3d9; color:var(--ink); }.delete-account:hover { background:var(--orange); } @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, .step-image-row { 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 b431b9d..a4a1170 100644 --- a/templates/admin_users.html +++ b/templates/admin_users.html @@ -4,13 +4,13 @@
ACCOUNT QUEUE{{ users|length }} REGISTERED
diff --git a/test_account_lifecycle.py b/test_account_lifecycle.py new file mode 100644 index 0000000..e5b1e9e --- /dev/null +++ b/test_account_lifecycle.py @@ -0,0 +1,103 @@ +import tempfile +import unittest +from pathlib import Path + +import app as application +from werkzeug.security import generate_password_hash + + +class AccountLifecycleTestCase(unittest.TestCase): + def setUp(self): + self.temp_directory = tempfile.TemporaryDirectory() + self.database_path = Path(self.temp_directory.name) / "test.db" + self.upload_directory = Path(self.temp_directory.name) / "uploads" + self.original_config = application.app.config.copy() + application.app.config.update( + DATABASE=self.database_path, + UPLOAD_FOLDER=self.upload_directory, + TESTING=True, + WTF_CSRF_ENABLED=False, + ) + with application.app.app_context(): + application.init_db() + db = application.get_db() + self.admin_id = db.execute( + """INSERT INTO users (username, email, email_verified, password_hash, created_at, is_approved, role, mfa_enabled) + VALUES (?, ?, 1, ?, ?, 1, 'admin', 1)""", + ("administrator", "admin@example.com", generate_password_hash("admin-password"), "2026-09-01T00:00:00+00:00"), + ).lastrowid + self.member_id = db.execute( + """INSERT INTO users (username, email, email_verified, password_hash, created_at, is_approved, role, mfa_enabled) + VALUES (?, ?, 1, ?, ?, 1, 'member', 1)""", + ("member", "member@example.com", generate_password_hash("member-password"), "2026-09-01T00:00:00+00:00"), + ).lastrowid + db.commit() + self.client = application.app.test_client() + + def tearDown(self): + application.app.config.update(self.original_config) + self.temp_directory.cleanup() + + def sign_in_as_admin(self): + with self.client.session_transaction() as session: + session["user_id"] = self.admin_id + session["mfa_verified"] = True + + def test_freeze_blocks_login_and_active_session(self): + self.sign_in_as_admin() + response = self.client.post(f"/admin/users/{self.member_id}/freeze") + self.assertEqual(response.status_code, 302) + with application.app.app_context(): + self.assertEqual(application.get_db().execute("SELECT is_frozen FROM users WHERE id = ?", (self.member_id,)).fetchone()[0], 1) + + response = self.client.post("/login", data={"username": "member", "password": "member-password"}) + self.assertIn(b"This account has been frozen by an administrator.", response.data) + with self.client.session_transaction() as session: + session["user_id"] = self.member_id + session["mfa_verified"] = True + response = self.client.get("/write") + self.assertEqual(response.status_code, 302) + self.assertIn("/login", response.location) + with self.client.session_transaction() as session: + self.assertNotIn("user_id", session) + + def test_delete_member_removes_related_data_and_images(self): + self.upload_directory.mkdir() + primary_image = self.upload_directory / "primary.webp" + inline_image = self.upload_directory / "inline.webp" + primary_image.write_bytes(b"primary") + inline_image.write_bytes(b"inline") + with application.app.app_context(): + db = application.get_db() + post_id = db.execute( + """INSERT INTO posts (title, slug, excerpt, body, category, image_filename, author_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ("Member post", "member-post", "Excerpt", "Body", "Code", primary_image.name, self.member_id, "2026-09-01T00:00:00+00:00", "2026-09-01T00:00:00+00:00"), + ).lastrowid + db.execute("INSERT INTO post_images (post_id, filename, caption, position, created_at) VALUES (?, ?, ?, ?, ?)", (post_id, inline_image.name, "", 1, "2026-09-01T00:00:00+00:00")) + db.execute("INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?)", (self.member_id, "reset-token", "2026-10-01T00:00:00+00:00", "2026-09-01T00:00:00+00:00")) + db.execute("INSERT INTO email_verification_tokens (user_id, email_address, token_hash, token_type, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)", (self.member_id, "member@example.com", "verification-token", "registration", "2026-10-01T00:00:00+00:00", "2026-09-01T00:00:00+00:00")) + db.commit() + + self.sign_in_as_admin() + self.assertEqual(self.client.post(f"/admin/users/{self.member_id}/delete").status_code, 302) + with application.app.app_context(): + db = application.get_db() + self.assertIsNone(db.execute("SELECT id FROM users WHERE id = ?", (self.member_id,)).fetchone()) + self.assertIsNone(db.execute("SELECT id FROM posts WHERE id = ?", (post_id,)).fetchone()) + self.assertEqual(db.execute("SELECT COUNT(*) FROM post_images WHERE post_id = ?", (post_id,)).fetchone()[0], 0) + self.assertEqual(db.execute("SELECT COUNT(*) FROM password_reset_tokens WHERE user_id = ?", (self.member_id,)).fetchone()[0], 0) + self.assertEqual(db.execute("SELECT COUNT(*) FROM email_verification_tokens WHERE user_id = ?", (self.member_id,)).fetchone()[0], 0) + self.assertFalse(primary_image.exists()) + self.assertFalse(inline_image.exists()) + + def test_administrator_accounts_cannot_be_frozen_or_deleted(self): + self.sign_in_as_admin() + self.assertEqual(self.client.post(f"/admin/users/{self.admin_id}/freeze").status_code, 302) + self.assertEqual(self.client.post(f"/admin/users/{self.admin_id}/delete").status_code, 404) + with application.app.app_context(): + self.assertEqual(application.get_db().execute("SELECT is_frozen FROM users WHERE id = ?", (self.admin_id,)).fetchone()[0], 0) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file