From 92602ed9efd1de51a86971f927911ac343349a32 Mon Sep 17 00:00:00 2001 From: Xusheng Date: Sat, 25 Jul 2026 22:54:47 -0400 Subject: [PATCH 1/2] Let users request account deletion via a reviewer-approved queue (#164) Adds a self-service account deletion flow: - Logged-in users can request deletion from their profile ("Delete Account"), confirming with their password. Requests land in a pending queue instead of deleting immediately. - Reviewers see the queue on the dashboard; an admin approves a request, which runs the existing full account deletion and emails the (now former) user a confirmation. Any reviewer can reject, which notifies the requester in-app. - The requester's email is denormalized onto the request so the confirmation email can be sent after the user document is gone. - FAQ "How do I delete my account?" now documents the self-service flow, the 30-day processing window (usually much faster), the reset-then- login path when the password is forgotten, and the email fallback when the registered address is also lost. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/__init__.py | 2 + app/controllers/account_deletion.py | 91 +++++++++++ app/models/account_deletion_request.py | 121 ++++++++++++++ review/routes.py | 153 +++++++++++++++++- .../reviewer/accountdeletionrequests.html | 78 +++++++++ review/templates/reviewer/dashboard.html | 7 + templates/faq/faq.html | 14 +- templates/user/delete-account.html | 70 ++++++++ templates/user/read.html | 2 + tests/test_account_deletion.py | 139 ++++++++++++++++ 10 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 app/controllers/account_deletion.py create mode 100644 app/models/account_deletion_request.py create mode 100644 review/templates/reviewer/accountdeletionrequests.html create mode 100644 templates/user/delete-account.html create mode 100644 tests/test_account_deletion.py diff --git a/app/controllers/__init__.py b/app/controllers/__init__.py index b2a40f3..690bfbd 100644 --- a/app/controllers/__init__.py +++ b/app/controllers/__init__.py @@ -20,6 +20,7 @@ def register_blueprints(app): from app.controllers.rules import rules_bp from app.controllers.password import password_bp from app.controllers.password_reset import password_reset_bp + from app.controllers.account_deletion import account_deletion_bp from app.controllers.static import static_bp from app.controllers.error import error_bp @@ -38,5 +39,6 @@ def register_blueprints(app): app.register_blueprint(rules_bp) app.register_blueprint(password_bp) app.register_blueprint(password_reset_bp) + app.register_blueprint(account_deletion_bp) app.register_blueprint(static_bp) app.register_blueprint(error_bp) diff --git a/app/controllers/account_deletion.py b/app/controllers/account_deletion.py new file mode 100644 index 0000000..f74b7c7 --- /dev/null +++ b/app/controllers/account_deletion.py @@ -0,0 +1,91 @@ +""" +Account deletion controller - let a logged-in user request deletion of their +own account. The request lands in a reviewer queue; an admin approves it, which +runs the actual deletion and emails the user. +""" + +from flask import Blueprint, render_template, request, session +from app.models.user import user_by_name +from app.models.account_deletion_request import ( + account_deletion_request_create, + pending_account_deletion_request_by_user, +) +from app.services.passhash import match_string +from app.services.limiter import limit +from app.controllers.decorators import login_required + +account_deletion_bp = Blueprint('account_deletion', __name__) + + +@account_deletion_bp.route('/delete-account', methods=['GET']) +@login_required +def delete_account_get(): + """Display the account deletion request form.""" + username = session.get('name') + + already_pending = False + try: + already_pending = bool( + pending_account_deletion_request_by_user(username) + ) + except Exception as e: + print(f"Error checking pending deletion request: {e}") + + return render_template('user/delete-account.html', + already_pending=already_pending, + message=None, + submitted=False) + + +@account_deletion_bp.route('/delete-account', methods=['POST']) +@login_required +@limit("5 per hour", key_func=lambda: session.get('name')) +def delete_account_post(): + """Create a pending account deletion request for the current user.""" + username = session.get('name') + if not username: + return 'User not logged in or session invalid', 401 + + password = request.form.get('password', '') + note = request.form.get('note', '').strip() + + try: + user = user_by_name(username) + except Exception as e: + print(f"Error: User not found: {e}") + return render_template('user/delete-account.html', + already_pending=False, + message="User not found.", + submitted=False) + + # Require the current password to confirm the request. This prevents an + # unattended/hijacked session from deleting the account. + if not password or not match_string(user['password'], password): + return render_template('user/delete-account.html', + already_pending=False, + message="Password is incorrect.", + submitted=False) + + # Don't stack duplicate pending requests for the same user. + try: + if pending_account_deletion_request_by_user(username): + return render_template('user/delete-account.html', + already_pending=True, + message=None, + submitted=False) + except Exception as e: + print(f"Error checking pending deletion request: {e}") + + try: + account_deletion_request_create(username, user.get('email', ''), note) + except Exception as e: + print(f"Error creating deletion request: {e}") + return render_template('user/delete-account.html', + already_pending=False, + message="Could not submit your request. Please try again later.", + submitted=False) + + return render_template('user/delete-account.html', + already_pending=True, + message=None, + submitted=True) diff --git a/app/models/account_deletion_request.py b/app/models/account_deletion_request.py new file mode 100644 index 0000000..a4b27d0 --- /dev/null +++ b/app/models/account_deletion_request.py @@ -0,0 +1,121 @@ +""" +Account deletion request model. + +A logged-in user can request that their own account be deleted. Requests land +in a pending queue that reviewers approve or reject; approving a request runs the +full account deletion and emails the (now former) user. The requester's email is +denormalized onto the request so the confirmation email can still be sent after +the user document is gone. +""" + +from datetime import datetime +from bson import ObjectId +from pymongo import DESCENDING, ReturnDocument +from app.services.database import get_collection, check_connection +from app.models.errors import ErrNoResult, ErrUnavailable + +# Request lifecycle states. +STATUS_PENDING = 'pending' +STATUS_APPROVED = 'approved' +STATUS_REJECTED = 'rejected' + + +def account_deletion_request_create(username, email, note=''): + """Create a pending account deletion request. + + Args: + username: Username of the requesting user + email: The user's email (denormalized for the confirmation email) + note: Optional free-text reason from the requester + + Returns: + The inserted request document. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('account_deletion_request') + obj_id = ObjectId() + + request = { + '_id': obj_id, + 'hexid': str(obj_id), + 'requester': username, + 'email': email, + 'note': note or '', + 'status': STATUS_PENDING, + 'created_at': datetime.utcnow(), + 'reviewed_by': None, + 'reviewed_at': None, + } + + collection.insert_one(request) + return request + + +def account_deletion_requests_pending(): + """Return all pending account deletion requests, newest first.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('account_deletion_request') + return list(collection.find({'status': STATUS_PENDING}) + .sort('created_at', DESCENDING)) + + +def count_pending_account_deletion_requests(): + """Count pending account deletion requests.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('account_deletion_request') + return collection.count_documents({'status': STATUS_PENDING}) + + +def account_deletion_request_by_hexid(hexid): + """Get an account deletion request by its hex ID. + + Raises: + ErrNoResult: If no request matches. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('account_deletion_request') + request = collection.find_one({'hexid': hexid}) + if not request: + raise ErrNoResult("Account deletion request not found") + return request + + +def account_deletion_request_set_status(hexid, status, reviewer): + """Mark a request approved/rejected and stamp the reviewer. + + Returns: + The updated request document, or None if not found. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('account_deletion_request') + return collection.find_one_and_update( + {'hexid': hexid}, + {'$set': { + 'status': status, + 'reviewed_by': reviewer, + 'reviewed_at': datetime.utcnow(), + }}, + return_document=ReturnDocument.AFTER + ) + + +def pending_account_deletion_request_by_user(username): + """Return a user's existing pending deletion request, if any (anti-dup).""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('account_deletion_request') + return collection.find_one({ + 'requester': username, + 'status': STATUS_PENDING, + }) diff --git a/review/routes.py b/review/routes.py index 126f4c1..7c2d778 100644 --- a/review/routes.py +++ b/review/routes.py @@ -49,6 +49,11 @@ label_requests_pending, count_pending_label_requests, label_request_by_hexid, label_request_set_status, STATUS_APPROVED, STATUS_REJECTED, ) +from app.models.account_deletion_request import ( + account_deletion_requests_pending, count_pending_account_deletion_requests, + account_deletion_request_by_hexid, account_deletion_request_set_status, +) +from app.services.email import send_email from app.models.errors import ErrNoResult @@ -1377,13 +1382,20 @@ def dashboard(current_user): print(f"Error counting label requests: {e}") labelreq_cnt = 0 + try: + acctdel_cnt = count_pending_account_deletion_requests() + except Exception as e: + print(f"Error counting account deletion requests: {e}") + acctdel_cnt = 0 + return render_template( 'reviewer/dashboard.html', user=current_user['username'], is_admin=current_user['is_admin'], solution_cnt=count_pending_solutions(), crackme_cnt=count_pending_items('crackme'), - labelreq_cnt=labelreq_cnt + labelreq_cnt=labelreq_cnt, + acctdel_cnt=acctdel_cnt ) @@ -1830,6 +1842,145 @@ def rejectlabelrequest(current_user): return redirect(url_for('reviewer.labelrequests', message="Request rejected")) +# ============================================================================= +# Route Handlers - Account Deletion Requests +# ============================================================================= + +def send_account_deleted_email(email, username): + """Email a user confirming their account has been deleted. + + Best-effort: returns True on success, False otherwise. Never raises. + """ + if not email: + return False + + subject = "Your crackmes.one account has been deleted" + body = ( + f"Hello {username},\n\n" + "As you requested, your crackmes.one account and all of its associated " + "data (crackmes, writeups, comments, and ratings) have been permanently " + "deleted.\n\n" + "If you did not request this, or you believe this was a mistake, please " + "reply to this email or contact us at crackmesone@gmail.com.\n\n" + "Thanks for having been part of the community.\n" + "- The crackmes.one team" + ) + try: + return send_email(email, subject, body) + except Exception as e: + print(f"Error sending account deletion email: {e}") + return False + + +@reviewer_bp.route('/accountdeletionrequests') +@token_required +def accountdeletionrequests(current_user): + """List pending account deletion requests for review.""" + try: + requests_list = account_deletion_requests_pending() + except Exception as e: + print(f"Error loading account deletion requests: {e}") + requests_list = [] + + return render_template( + 'reviewer/accountdeletionrequests.html', + user=current_user['username'], + is_admin=current_user['is_admin'], + requests=requests_list, + message=request.args.get('message') + ) + + +@reviewer_bp.route('/approveaccountdeletion', methods=['POST']) +@admin_required +def approveaccountdeletion(current_user): + """Approve an account deletion request and delete the account (admin only).""" + validate_csrf_token() + req_hexid = request.form.get('uuid') + + try: + del_request = account_deletion_request_by_hexid(req_hexid) + except ErrNoResult: + return redirect(url_for('reviewer.accountdeletionrequests', message="Request not found")) + + if del_request.get('status') != 'pending': + return redirect(url_for('reviewer.accountdeletionrequests', message="Request already handled")) + + username = del_request.get('requester') + email = del_request.get('email', '') + + # The stored email is what the account had when the request was made; fall + # back to the current account email so deletion still targets the right user. + delete_email = email + if username: + user = g_crackmesone_db.user.find_one({'name': username}) + if user and user.get('email'): + delete_email = user['email'] + email = email or user['email'] + + if not delete_email: + return redirect(url_for( + 'reviewer.accountdeletionrequests', + message="Request has no email on file; cannot delete" + )) + + result = delete_user_account(delete_email, admin_username=current_user['username']) + success = "successful" in result.lower() + + log_reviewer_operation( + "approve_account_deletion", current_user['username'], + {"request": req_hexid, "user": username, "email": delete_email, "result": result}, + success + ) + + if not success: + return redirect(url_for('reviewer.accountdeletionrequests', message=result)) + + account_deletion_request_set_status(req_hexid, STATUS_APPROVED, current_user['username']) + + email_sent = send_account_deleted_email(email, username) + + msg = f"Account '{username}' deleted." + msg += " Confirmation email sent." if email_sent else " (Confirmation email not sent.)" + return redirect(url_for('reviewer.accountdeletionrequests', message=msg)) + + +@reviewer_bp.route('/rejectaccountdeletion', methods=['POST']) +@token_required +def rejectaccountdeletion(current_user): + """Reject an account deletion request.""" + validate_csrf_token() + req_hexid = request.form.get('uuid') + reject_reason = request.form.get('reject_reason') + + try: + del_request = account_deletion_request_by_hexid(req_hexid) + except ErrNoResult: + return redirect(url_for('reviewer.accountdeletionrequests', message="Request not found")) + + if del_request.get('status') != 'pending': + return redirect(url_for('reviewer.accountdeletionrequests', message="Request already handled")) + + account_deletion_request_set_status(req_hexid, STATUS_REJECTED, current_user['username']) + + log_reviewer_operation( + "reject_account_deletion", current_user['username'], + {"request": req_hexid, "user": del_request.get('requester'), "reason": reject_reason}, + True + ) + + try: + notif = "Your account deletion request has been declined." + if reject_reason: + notif += f" Reason: {html_escape(reject_reason)}" + notif += " If you still wish to delete your account, please contact crackmesone@gmail.com." + send_user_notification(del_request['requester'], notif) + except Exception as e: + print(f"Notification error: {e}") + + return redirect(url_for('reviewer.accountdeletionrequests', message="Request rejected")) + + # ============================================================================= # Route Handlers - Admin: Delete Approved Content # ============================================================================= diff --git a/review/templates/reviewer/accountdeletionrequests.html b/review/templates/reviewer/accountdeletionrequests.html new file mode 100644 index 0000000..c6a3469 --- /dev/null +++ b/review/templates/reviewer/accountdeletionrequests.html @@ -0,0 +1,78 @@ + + + + + Account deletion requests + + + + + + + + + +
+ {% if message %} +
{{ message }}
+ {% endif %} + +

Pending account deletion requests

+ + {% if not is_admin %} +
+ You can review these requests, but only an admin can approve (and thereby + permanently delete the account). +
+ {% endif %} + + {% if not requests %} +

No pending account deletion requests.

+ {% else %} + {% for req in requests %} +
+
+

+ {{ req.requester }} + — {{ req.email }} +
+ Requested on {{ req.created_at }} +

+ {% if req.note %} +

Reason: {{ req.note }}

+ {% endif %} + + {% if is_admin %} +
+ + + +
+ {% endif %} + +
+ + + + +
+
+
+ {% endfor %} + {% endif %} +
+ + + diff --git a/review/templates/reviewer/dashboard.html b/review/templates/reviewer/dashboard.html index 8a514b7..a3ce053 100644 --- a/review/templates/reviewer/dashboard.html +++ b/review/templates/reviewer/dashboard.html @@ -29,6 +29,7 @@

Welcome, {{ user }}!{% if is_admin %} (Admin){% endif %}

Review solutions Review crackmes Review label requests + Account deletion requests {% if is_admin %} Delete solution Delete crackme @@ -66,6 +67,12 @@

{{ crackme_cnt }}


{{ labelreq_cnt }}


+
+
+

The number of account deletion requests to review:

+

{{ acctdel_cnt }}


+
+
diff --git a/templates/faq/faq.html b/templates/faq/faq.html index d5c5a97..515e740 100644 --- a/templates/faq/faq.html +++ b/templates/faq/faq.html @@ -263,7 +263,19 @@

I didn't receive the password reset email.

How do I delete my account? #

-

Please email us at crackmesone@gmail.com to request account deletion.

+

You can request account deletion yourself: log in, click "Profile", scroll to the + bottom of the page, and click "Delete Account". You'll be asked to confirm your + password. Your request is then reviewed by an admin and, once approved, your account + and all associated data (crackmes, writeups, comments, and ratings) are permanently + deleted. We'll email you at your registered address once the deletion is complete.

+

Account deletion requests are processed within 30 days, though in practice they + are usually handled much faster.

+

If you cannot log in but still have access to the email address you registered with, + first use the Forgot Password page to reset your + password, then log in and request account deletion as described above.

+

If you cannot log in and no longer have access to your registered email address, please + email us at crackmesone@gmail.com so we can help verify your identity and process the + deletion manually.

I cannot speak or see most channels on Discord. #

diff --git a/templates/user/delete-account.html b/templates/user/delete-account.html new file mode 100644 index 0000000..a198107 --- /dev/null +++ b/templates/user/delete-account.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% block title %}Delete Account{% endblock %} +{% block page_title %}Delete Account{% endblock %} +{% block head %}{% endblock %} +{% block content %} + +
+
+
+

Request account deletion

+ + {% if submitted %} +
+ Your account deletion request has been submitted. It will be reviewed and + processed within 30 days, though usually much sooner. We'll email you once + your account has been deleted. +
+ {% elif already_pending %} +
+ You already have a pending account deletion request. It will be reviewed and + processed within 30 days, though usually much sooner. If you change your mind, + please contact us at + crackmesone@gmail.com. +
+ {% else %} + {% if message %} +
{{ message }}
+ {% endif %} + +
+ Warning: Deleting your account is permanent and cannot be + undone. All of your crackmes, writeups, comments, and ratings will be + permanently removed, along with everything posted on your + crackmes (other users' writeups and comments on them). +
+ +

Your request will be reviewed by an admin and processed within 30 days, though + usually much sooner. Once your account has been deleted, we'll send a + confirmation to your registered email address.

+ +
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+ {% endif %} +
+
+
+ +{% endblock %} +{% block foot %}{% endblock %} diff --git a/templates/user/read.html b/templates/user/read.html index 260c657..38e01d7 100644 --- a/templates/user/read.html +++ b/templates/user/read.html @@ -144,6 +144,8 @@

Comments

{% if viewingOwnPage %} {% endif %} diff --git a/tests/test_account_deletion.py b/tests/test_account_deletion.py new file mode 100644 index 0000000..7a6cbe0 --- /dev/null +++ b/tests/test_account_deletion.py @@ -0,0 +1,139 @@ +"""Tests for the user-requested account deletion workflow.""" + + +def _admin_client(app): + from review import routes + + routes.users['admin'] = { + 'password_hash': routes.hash_string('admin-passwordtest-reviewer-salt'), + 'is_admin': True, + } + client = app.test_client() + with client.session_transaction() as session: + session['_reviewer_user'] = 'admin' + session['_reviewer_is_admin'] = True + session['_reviewer_csrf_token'] = 'admin-csrf' + return client + + +# --------------------------------------------------------------------------- +# User-facing request creation +# --------------------------------------------------------------------------- + +def test_user_can_request_account_deletion(alice_client, db): + response = alice_client.post('/delete-account', data={ + 'password': 'alice-password', + 'note': 'Leaving the site', + }) + + assert response.status_code == 200 + req = db.account_deletion_request.find_one({'requester': 'alice'}) + assert req is not None + assert req['status'] == 'pending' + assert req['email'] == 'alice@example.test' + assert req['note'] == 'Leaving the site' + + +def test_deletion_request_requires_correct_password(alice_client, db): + response = alice_client.post('/delete-account', data={ + 'password': 'wrong-password', + }) + + assert response.status_code == 200 + assert db.account_deletion_request.count_documents({'requester': 'alice'}) == 0 + + +def test_deletion_request_not_duplicated(alice_client, db): + for _ in range(2): + alice_client.post('/delete-account', data={'password': 'alice-password'}) + + assert db.account_deletion_request.count_documents({ + 'requester': 'alice', 'status': 'pending' + }) == 1 + + +def test_deletion_request_requires_login(client): + response = client.post('/delete-account', data={'password': 'x'}) + # login_required redirects anonymous users away. + assert response.status_code in (301, 302) + + +def test_delete_account_page_renders(alice_client): + response = alice_client.get('/delete-account') + assert response.status_code == 200 + assert b'Request account deletion' in response.data + + +# --------------------------------------------------------------------------- +# Reviewer / admin side +# --------------------------------------------------------------------------- + +def test_reviewer_can_list_deletion_requests(reviewer_client, db): + from app.models.account_deletion_request import account_deletion_request_create + + account_deletion_request_create('alice', 'alice@example.test') + response = reviewer_client.get('/review/accountdeletionrequests') + + assert response.status_code == 200 + assert b'alice' in response.data + + +def test_admin_approves_deletion_and_emails_user(app, db, alice, monkeypatch): + from review import routes + from app.models.account_deletion_request import account_deletion_request_create + + req = account_deletion_request_create('alice', 'alice@example.test') + + sent = {} + monkeypatch.setattr( + routes, 'send_email', + lambda to, subject, body: sent.update(to=to, subject=subject) or True + ) + monkeypatch.setattr(routes, 'log_reviewer_operation', lambda *a, **k: None) + + client = _admin_client(app) + response = client.post('/review/approveaccountdeletion', data={ + 'uuid': req['hexid'], 'csrf_token': 'admin-csrf', + }) + + assert response.status_code == 302 + assert db.user.find_one({'name': 'alice'}) is None + assert db.account_deletion_request.find_one( + {'hexid': req['hexid']})['status'] == 'approved' + assert sent['to'] == 'alice@example.test' + routes.users.pop('admin', None) + + +def test_non_admin_cannot_approve_deletion(reviewer_client, db, alice): + from app.models.account_deletion_request import account_deletion_request_create + + req = account_deletion_request_create('alice', 'alice@example.test') + response = reviewer_client.post('/review/approveaccountdeletion', data={ + 'uuid': req['hexid'], 'csrf_token': 'test-csrf-token', + }) + + assert response.status_code == 403 + # Account untouched, request still pending. + assert db.user.find_one({'name': 'alice'}) is not None + assert db.account_deletion_request.find_one( + {'hexid': req['hexid']})['status'] == 'pending' + + +def test_reviewer_rejects_deletion_request(reviewer_client, db, alice): + from review import routes + from app.models.account_deletion_request import account_deletion_request_create + + req = account_deletion_request_create('alice', 'alice@example.test') + + response = reviewer_client.post('/review/rejectaccountdeletion', data={ + 'uuid': req['hexid'], + 'reject_reason': 'Please contact us first', + 'csrf_token': 'test-csrf-token', + }) + + assert response.status_code == 302 + assert db.user.find_one({'name': 'alice'}) is not None + assert db.account_deletion_request.find_one( + {'hexid': req['hexid']})['status'] == 'rejected' + # Requester is notified. + assert db.notifications.count_documents({'user': 'alice'}) == 1 From 98b10ca421bbda07220eea504dee76e314879643 Mon Sep 17 00:00:00 2001 From: Xusheng Date: Sat, 25 Jul 2026 23:01:50 -0400 Subject: [PATCH 2/2] Render profile Change Password / Delete Account as buttons Style the two account-management links on the profile page as spectre buttons (Delete Account uses btn-error) instead of plain text links. Co-Authored-By: Claude Opus 4.8 (1M context) --- templates/user/read.html | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/templates/user/read.html b/templates/user/read.html index 38e01d7..cfc501a 100644 --- a/templates/user/read.html +++ b/templates/user/read.html @@ -143,9 +143,8 @@

Comments

{% if viewingOwnPage %} {% endif %}