Skip to content

Commit

Permalink
Feature: users from external auth can anonymize their own accounts.
Browse files Browse the repository at this point in the history
  • Loading branch information
liffiton committed Dec 24, 2024
1 parent 801312e commit a7fd553
Show file tree
Hide file tree
Showing 3 changed files with 205 additions and 19 deletions.
69 changes: 64 additions & 5 deletions src/gened/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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"))
66 changes: 53 additions & 13 deletions src/gened/templates/profile_view.html
Original file line number Diff line number Diff line change
Expand Up @@ -108,18 +108,28 @@ <h2 class="title is-size-3">
</dl>

<h2 class="title is-size-3">Your Data</h2>
<button class="button ml-5 icon-text is-light is-danger" onclick="document.querySelector('#delete_data_dialog').showModal()">
<span class="icon">
<svg aria-hidden="true"><use href="#svg_trash" /></svg>
</span>
<span>Delete your data</span>
</button>
<div class="buttons">
{% if user.provider_name not in ['lti', 'demo', 'local'] %}
<button class="button ml-5 icon-text is-info" onclick="document.querySelector('#anonymize_dialog').showModal()">
<span class="icon">
<svg aria-hidden="true"><use href="#svg_user" /></svg>
</span>
<span>Anonymize account</span>
</button>
{% endif %}
<button class="button ml-5 icon-text is-danger" onclick="document.querySelector('#delete_dialog').showModal()">
<span class="icon">
<svg aria-hidden="true"><use href="#svg_trash" /></svg>
</span>
<span>Delete your account</span>
</button>
</div>

</div>

<dialog id="delete_data_dialog" style="max-width: 50em; min-width: min(32em, 100vw);">
<div class="content box" style="border: 2px solid #f009; background: #fff8f8;">
<h2 class="has-text-danger">Delete Your Data</h2>
<dialog id="delete_dialog" style="max-width: 50em; min-width: min(32em, 100vw);">
<div class="content box" style="border: 3px solid #a00; background: #fff8f8;">
<h2 class="has-text-danger-dark">Delete Your Account</h2>
<div class="content">
<p>This will:
<ul>
Expand All @@ -132,8 +142,8 @@ <h2 class="has-text-danger">Delete Your Data</h2>
{% if auth.user.auth_provider == 'lti' %}
<p>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.</p>
{% endif %}
<p>Type "DELETE" in the text box to confirm you want to delete your data.</p>
<p class="has-text-danger"><strong class="has-text-danger">Warning:</strong> Deleting your data cannot be undone.</p>
<p>Type "DELETE" in the text box to confirm you want to delete your account.</p>
<p class="has-text-danger-dark"><strong class="has-text-danger-dark">Warning:</strong> Deleting your data cannot be undone.</p>
</div>
<form method="POST" action="{{ url_for('profile.delete_data') }}">
{% if created_classes %}
Expand All @@ -143,13 +153,13 @@ <h2 class="has-text-danger">Delete Your Data</h2>
</div>
<div class="control">
<button disabled type="submit" class="button is-danger">
Delete Class Data
Delete Account
</button>
</div>
</div>
<div class="notification is-danger">
<p><strong>Classes cannot be orphaned.</strong></p>
<p>Before you can delete your data, you must first delete all classes you have created:</p>
<p>Before you can delete your account, you must first delete all classes you have created:</p>
<ul>
{% for class in created_classes %}
<li>{{ class.name }}</li>
Expand Down Expand Up @@ -209,8 +219,38 @@ <h2>Create a New Class</h2>
</div>
</dialog>
{% endif %}

{% if not auth.cur_class %}
{% include "free_query_dialog.html" %}
{% endif %}

{% if user.provider_name not in ['lti', 'demo', 'local'] %}
<dialog id="anonymize_dialog" style="max-width: 50em; min-width: min(32em, 100vw);">
<div class="content box">
<h2 class="has-text-info">Anonymize Your Account</h2>
<div class="content">
<p>This will:
<ul>
<li>Remove your name, email, and username from your account</li>
<li>Give your account a new, anonymous name</li>
<li>Keep your account active and your usage data intact</li>
</ul>
</p>
<p class="has-text-danger-dark"><strong class="has-text-danger-dark">Warning:</strong> Account anonymization cannot be undone.</p>
</div>
<form method="POST" action="{{ url_for('profile.anonymize') }}">
<div class="field is-grouped">
<div class="control">
<button type="submit" class="button is-info">Anonymize Account</button>
</div>
<div class="control">
<button class="button" type="submit" formnovalidate formmethod="dialog">Cancel</button>
</div>
</div>
</form>
</div>
</dialog>
{% endif %}

</section>
{% endblock %}
89 changes: 88 additions & 1 deletion tests/test_privacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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']

0 comments on commit a7fd553

Please sign in to comment.