diff --git a/src/gened/profile.py b/src/gened/profile.py index bdef131..173f8f0 100644 --- a/src/gened/profile.py +++ b/src/gened/profile.py @@ -2,19 +2,32 @@ # # SPDX-License-Identifier: AGPL-3.0-only -from flask import Blueprint, flash, redirect, render_template, request, session, url_for +from flask import ( + Blueprint, + current_app, + flash, + redirect, + render_template, + request, + session, + url_for, +) from werkzeug.wrappers.response import Response -from .auth import get_auth, login_required +from .auth import generate_anon_username, get_auth, login_required from .data_deletion import delete_user_data from .db import get_db from .redir import safe_redirect bp = Blueprint('profile', __name__, url_prefix="/profile", template_folder='templates') +@bp.before_request +@login_required +def before_request() -> None: + """ Apply decorator to protect all profile blueprint endpoints. """ + @bp.route("/") -@login_required def main() -> str: db = get_db() auth = get_auth() @@ -78,7 +91,6 @@ def main() -> str: @bp.route("/delete_data", methods=['POST']) -@login_required def delete_data() -> Response: # Require explicit confirmation if request.form.get('confirm_delete') != 'DELETE': @@ -126,7 +138,54 @@ def delete_data() -> Response: db.commit() - # Clear their session and log them out + # Clear their session to log them out session.clear() + + current_app.logger.info(f"Account deleted: ID {user_id}") flash("Your data has been deleted and your account has been deactivated.", "success") return redirect(url_for("auth.login")) + + +@bp.route("/anonymize", methods=['POST']) +def anonymize() -> Response: + auth = get_auth() + user_id = auth.user_id + assert user_id is not None # due to @login_required + db = get_db() + + # Check if this is an external, non-LTI account + auth_row = db.execute(""" + SELECT auth_external.user_id, auth_providers.name as provider + FROM auth_external + JOIN auth_providers ON auth_providers.id = auth_external.auth_provider + WHERE user_id=? + """, [user_id]).fetchone() + + if not auth_row or auth_row['provider'] == 'lti': + flash("Account anonymization is only available to external, non-LTI accounts.", "warning") + return safe_redirect(request.referrer, default_endpoint="profile.main") + + # Generate new anonymous display name + new_name = generate_anon_username() + + # Update user record to remove personal info + db.execute(""" + UPDATE users + SET full_name = ?, + email = NULL, + auth_name = NULL + WHERE id = ? + """, [new_name, user_id]) + + # Mark external auth entry as anonymous + db.execute(""" + UPDATE auth_external + SET is_anon = 1 + WHERE user_id = ? + """, [user_id]) + + db.commit() + + current_app.logger.info(f"Account anonymized: ID {user_id}") + flash("Your account has been anonymized.", "success") + return redirect(url_for("profile.main")) diff --git a/src/gened/templates/profile_view.html b/src/gened/templates/profile_view.html index 0a55298..8e9f959 100644 --- a/src/gened/templates/profile_view.html +++ b/src/gened/templates/profile_view.html @@ -108,18 +108,28 @@

Your Data

- +
+ {% if user.provider_name not in ['lti', 'demo', 'local'] %} + + {% endif %} + +
- -
-

Delete Your Data

+ +
+

Delete Your Account

This will:

    @@ -132,8 +142,8 @@

    Delete Your Data

    {% if auth.user.auth_provider == 'lti' %}

    This user was created using a link from an LMS. If you connect to CodeHelp from your LMS after deleting your data here, you will create a new user on CodeHelp separate from this one.

    {% endif %} -

    Type "DELETE" in the text box to confirm you want to delete your data.

    -

    Warning: Deleting your data cannot be undone.

    +

    Type "DELETE" in the text box to confirm you want to delete your account.

    +

    Warning: Deleting your data cannot be undone.

{% if created_classes %} @@ -143,13 +153,13 @@

Delete Your Data

Classes cannot be orphaned.

-

Before you can delete your data, you must first delete all classes you have created:

+

Before you can delete your account, you must first delete all classes you have created:

{% endif %} + {% if not auth.cur_class %} {% include "free_query_dialog.html" %} {% endif %} + + {% if user.provider_name not in ['lti', 'demo', 'local'] %} + +
+

Anonymize Your Account

+
+

This will: +

    +
  • Remove your name, email, and username from your account
  • +
  • Give your account a new, anonymous name
  • +
  • Keep your account active and your usage data intact
  • +
+

+

Warning: Account anonymization cannot be undone.

+
+ +
+
+ +
+
+ +
+
+ +
+
+ {% endif %} + {% endblock %} diff --git a/tests/test_privacy.py b/tests/test_privacy.py index abfc32a..3b746db 100644 --- a/tests/test_privacy.py +++ b/tests/test_privacy.py @@ -2,7 +2,9 @@ # # SPDX-License-Identifier: AGPL-3.0-only -import pytest +import re + +from flask import url_for from gened.db import get_db @@ -263,3 +265,88 @@ def test_delete_class_unauthorized(app, client, auth): with app.app_context(): verify_row_count("classes", "WHERE id = ? AND name != '[deleted]'", [class_id], 1, "Class should still exist") + +def test_anonymize_user_unauthorized(app, client): + """Test unauthorized access to user anonymization""" + # Test without login + response = client.post('/profile/anonymize') + assert response.status_code == 302 + assert response.location.startswith('/auth/login?') + + +def test_anonymize_user_provider_restrictions(app, client, auth): + """Test provider restrictions for anonymization""" + auth.login('testuser', 'testpassword') + + with app.app_context(): + db = get_db() + # Get original user data + init_user = db.execute("SELECT * FROM users WHERE auth_name='testuser'").fetchone() + + # Force-set current user as LTI user + db.execute(""" + UPDATE auth_external + SET auth_provider = (SELECT id FROM auth_providers WHERE name = 'lti') + WHERE user_id = (SELECT id FROM users WHERE auth_name = 'testuser') + """) + db.commit() + + response = client.post('/profile/anonymize', follow_redirects=True) + assert response.status_code == 200 + assert b"Account anonymization is only available to external, non-LTI accounts" in response.data + + # Verify nothing was anonymized + user = db.execute("SELECT * FROM users WHERE auth_name='testuser'").fetchone() + assert user['full_name'] == init_user['full_name'] + assert user['email'] == init_user['email'] + + +def test_anonymize_user_full_process(app, client, mock_oauth_patch): + """Test complete user anonymization process""" + # Set up mock OAuth login + with app.test_request_context(): + auth_url = url_for('oauth.auth', provider_name='google') + + # Perform OAuth login + with client: + response = client.get(auth_url) + assert response.status_code == 302 + + # Get initial state + with app.app_context(): + db = get_db() + initial_user = db.execute("SELECT * FROM users WHERE full_name=?", [mock_oauth_patch.test_user['name']]).fetchone() + user_id = initial_user['id'] + initial_auth = db.execute("SELECT * FROM auth_external WHERE user_id = ?", [user_id]).fetchone() + + # Verify initial state + assert not initial_auth['is_anon'] # This is the key state we're testing + + # Perform anonymization + response = client.post('/profile/anonymize') + assert response.status_code == 302 + assert response.location == "/profile/" + + # Verify final state + with app.app_context(): + db = get_db() + user = db.execute("SELECT * FROM users WHERE id = ?", [user_id]).fetchone() + auth = db.execute("SELECT * FROM auth_external WHERE user_id = ?", [user_id]).fetchone() + + # Check user record + assert user['full_name'] != initial_user['full_name'] + # Anonymous usernames are three capitalized words concatenated + assert re.match(r"^(?:[A-Z][a-z]+){3}$", user['full_name']) + assert user['email'] is None + assert user['auth_name'] is None + + # Check external auth set to anonymous + assert auth['is_anon'] == 1 + + # Verify other data remains intact + assert user['id'] == initial_user['id'] + assert user['auth_provider'] == initial_user['auth_provider'] + assert user['is_admin'] == initial_user['is_admin'] + assert user['is_tester'] == initial_user['is_tester'] + assert user['query_tokens'] == initial_user['query_tokens'] + assert user['last_class_id'] == initial_user['last_class_id']