Skip to content

Commit 09142a4

Browse files
Fix CAS login errors caused by duplicate user accounts (#390)
1 parent f722011 commit 09142a4

9 files changed

Lines changed: 342 additions & 24 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
This page tries to contain all use facing changes made on DocHub.
44

5+
# Unreleased
6+
7+
* Fix login errors when ULB changes your email or netid
8+
* Log CAS authentication failures in the admin for easier debugging
9+
510
# 2026.5.1
611

712
Moderation:

users/admin.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from django.contrib import admin
22

3-
from .models import User
3+
from .models import CasFailure, User
44

55

66
@admin.register(User)
@@ -64,3 +64,18 @@ class UserAdmin(admin.ModelAdmin):
6464
},
6565
),
6666
)
67+
68+
69+
@admin.register(CasFailure)
70+
class CasFailureAdmin(admin.ModelAdmin):
71+
list_display = ("code", "ticket", "ip_address", "created")
72+
list_filter = ("code",)
73+
search_fields = ("ticket", "details", "ip_address")
74+
date_hierarchy = "created"
75+
readonly_fields = ("code", "details", "ticket", "ip_address", "created")
76+
77+
def has_add_permission(self, request):
78+
return False
79+
80+
def has_change_permission(self, request, obj=None):
81+
return False

users/authBackend.py

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@
22
import xml.etree.ElementTree as ET
33

44
from django.conf import settings
5-
from django.db.models import Q
65
from django.urls import reverse
76

87
import requests
98
from furl import furl
109

11-
from users.models import User
10+
from users.models import CasFailure, User
1211

1312
logger = logging.getLogger(__name__)
1413

@@ -45,19 +44,42 @@ def authenticate(self, request, ticket=None):
4544
user_dict = self._parse_response(resp.text)
4645

4746
# Get or create a user from the parsed user_dict
47+
# Prefer matching by netid (primary CAS identifier), fall back to email
4848
try:
49-
user = User.objects.get(
50-
Q(netid=user_dict["netid"]) | Q(email=user_dict["email"])
51-
)
49+
user = User.objects.get(netid=user_dict["netid"])
5250
except User.DoesNotExist:
53-
user = User.objects.create_user(
54-
netid=user_dict["netid"],
55-
email=user_dict["email"],
56-
first_name=user_dict["first_name"],
57-
last_name=user_dict["last_name"],
58-
register_method=self.LOGIN_METHOD,
59-
)
51+
try:
52+
user = User.objects.get(email=user_dict["email"])
53+
except User.DoesNotExist:
54+
user = User.objects.create_user(
55+
netid=user_dict["netid"],
56+
email=user_dict["email"],
57+
first_name=user_dict["first_name"],
58+
last_name=user_dict["last_name"],
59+
register_method=self.LOGIN_METHOD,
60+
)
61+
62+
# Sync all fields from CAS on every login
63+
user.netid = user_dict["netid"]
64+
user.first_name = user_dict["first_name"]
65+
user.last_name = user_dict["last_name"]
6066
user.last_login_method = self.LOGIN_METHOD
67+
68+
# Only update email if no other user already has it
69+
conflict = (
70+
User.objects.filter(email=user_dict["email"]).exclude(pk=user.pk).first()
71+
)
72+
if conflict:
73+
CasFailure.objects.create(
74+
code="EMAIL_CONFLICT",
75+
details=(
76+
f"CAS returned email {user_dict['email']} for user {user.netid} (id={user.pk}), "
77+
f"but it belongs to user {conflict.netid} (id={conflict.pk})"
78+
),
79+
)
80+
else:
81+
user.email = user_dict["email"]
82+
6183
user.save()
6284

6385
return user
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from django.db import migrations
2+
3+
LEGACY_TABLES = [
4+
"actstream_action",
5+
"actstream_follow",
6+
"authtoken_token",
7+
]
8+
9+
10+
def remove_legacy_tables(apps, schema_editor):
11+
vendor = schema_editor.connection.vendor
12+
with schema_editor.connection.cursor() as cursor:
13+
for table in LEGACY_TABLES:
14+
if vendor == "sqlite":
15+
cursor.execute(f"DROP TABLE IF EXISTS {table}")
16+
else:
17+
cursor.execute(f"DROP TABLE IF EXISTS {table} CASCADE")
18+
19+
20+
class Migration(migrations.Migration):
21+
22+
dependencies = [
23+
("users", "0008_user_moderator_welcome_dismissed"),
24+
]
25+
26+
operations = [
27+
migrations.RunPython(remove_legacy_tables, migrations.RunPython.noop),
28+
]
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from django.db import migrations
2+
3+
4+
def delete_numeric_netid_users(apps, schema_editor):
5+
User = apps.get_model("users", "User")
6+
Document = apps.get_model("documents", "Document")
7+
Course = apps.get_model("catalog", "Course")
8+
9+
numeric_users = User.objects.filter(netid__regex=r"^\d+$")
10+
numeric_user_ids = set(numeric_users.values_list("id", flat=True))
11+
12+
if not numeric_user_ids:
13+
return
14+
15+
# Safety check: we expect ~64, more than 100 would be suspicious
16+
if len(numeric_user_ids) > 100:
17+
raise RuntimeError(
18+
f"Expected ~64 numeric-netid users, found {len(numeric_user_ids)}"
19+
)
20+
21+
# Safety check: none of these users should have documents
22+
doc_count = Document.objects.filter(user__in=numeric_user_ids).count()
23+
if doc_count:
24+
raise RuntimeError(
25+
f"Cannot delete numeric-netid users: {doc_count} documents would be lost"
26+
)
27+
28+
# Transfer course follows from duplicate numeric-netid users to their
29+
# original account (matched by email local part) where possible.
30+
for user in numeric_users.iterator():
31+
courses = Course.objects.filter(followed_by=user)
32+
if not courses.exists():
33+
continue
34+
35+
local_part = user.email.split("@")[0].lower()
36+
original = (
37+
User.objects.filter(email__istartswith=local_part + "@")
38+
.exclude(netid__regex=r"^\d+$")
39+
.exclude(id=user.id)
40+
.first()
41+
)
42+
43+
if original:
44+
for course in courses:
45+
course.followed_by.add(original)
46+
47+
deleted_count, _ = numeric_users.delete()
48+
print(f"\n Deleted {deleted_count} users with numeric netids")
49+
50+
51+
class Migration(migrations.Migration):
52+
53+
dependencies = [
54+
("users", "0009_remove_legacy_tables"),
55+
]
56+
57+
operations = [
58+
migrations.RunPython(delete_numeric_netid_users, migrations.RunPython.noop),
59+
]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Generated by Django 6.0.5 on 2026-05-15 20:21
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("users", "0010_delete_numeric_netid_users"),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name="CasFailure",
15+
fields=[
16+
(
17+
"id",
18+
models.BigAutoField(
19+
auto_created=True,
20+
primary_key=True,
21+
serialize=False,
22+
verbose_name="ID",
23+
),
24+
),
25+
("created", models.DateTimeField(auto_now_add=True)),
26+
("code", models.CharField(max_length=64)),
27+
("details", models.TextField(blank=True, default="")),
28+
("ticket", models.CharField(blank=True, default="", max_length=512)),
29+
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
30+
],
31+
options={
32+
"ordering": ["-created"],
33+
},
34+
),
35+
]

users/models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,17 @@ def get_short_name(self):
9797

9898
def initials(self):
9999
return self.first_name[0] + self.last_name[0]
100+
101+
102+
class CasFailure(models.Model):
103+
created = models.DateTimeField(auto_now_add=True)
104+
code = models.CharField(max_length=64)
105+
details = models.TextField(blank=True, default="")
106+
ticket = models.CharField(max_length=512, blank=True, default="")
107+
ip_address = models.GenericIPAddressField(null=True, blank=True)
108+
109+
class Meta:
110+
ordering = ["-created"]
111+
112+
def __str__(self):
113+
return f"{self.code} ({self.created:%Y-%m-%d %H:%M})"

users/tests/auth_backend_authenticate_test.py

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from responses import matchers
44

55
from users.authBackend import CasRequestError, UlbCasBackend
6-
from users.models import User
6+
from users.models import CasFailure, User
77

88
pytestmark = pytest.mark.django_db
99

@@ -70,3 +70,122 @@ def test_server_error(fake_base_url):
7070
UlbCasBackend().authenticate(None, ticket=ticket)
7171

7272
assert e.value.args[0].status_code == 500
73+
74+
75+
CAS_XML_TEMPLATE = """\
76+
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
77+
<cas:authenticationSuccess>
78+
<cas:user>{netid}</cas:user>
79+
<cas:attributes>
80+
<cas:mail>{email}</cas:mail>
81+
<cas:sn>{last_name}</cas:sn>
82+
<cas:givenName>{first_name}</cas:givenName>
83+
</cas:attributes>
84+
</cas:authenticationSuccess>
85+
</cas:serviceResponse>"""
86+
87+
TICKET = "test-ticket"
88+
SERVICE_MATCHER = matchers.query_string_matcher(
89+
f"ticket={TICKET}&service=http%3A%2F%2Fexample.com%2Fauth-ulb"
90+
)
91+
92+
93+
def _mock_cas_response(netid, email, first_name="Test", last_name="User"):
94+
xml = CAS_XML_TEMPLATE.format(
95+
netid=netid, email=email, first_name=first_name, last_name=last_name
96+
)
97+
responses.add(
98+
responses.GET,
99+
"https://auth.ulb.be/proxyValidate",
100+
body=xml,
101+
status=200,
102+
match=[SERVICE_MATCHER],
103+
)
104+
105+
106+
@responses.activate
107+
def test_netid_match_updates_email(fake_base_url):
108+
"""When netid matches an existing user, reuse it and update email."""
109+
User.objects.create_user(
110+
netid="glagaff", email="gaston.lagaffe@ulb.ac.be", first_name="Gaston"
111+
)
112+
113+
_mock_cas_response("glagaff", "gaston.lagaffe@ulb.be")
114+
user = UlbCasBackend().authenticate(None, ticket=TICKET)
115+
116+
assert user.netid == "glagaff"
117+
assert user.email == "gaston.lagaffe@ulb.be"
118+
assert User.objects.count() == 1
119+
120+
121+
@responses.activate
122+
def test_email_fallback_updates_netid(fake_base_url):
123+
"""When netid doesn't match but email does, reuse the user and update netid."""
124+
User.objects.create_user(
125+
netid="fantasio", email="fantasio@ulb.be", first_name="Fantasio"
126+
)
127+
128+
_mock_cas_response("fant0001", "fantasio@ulb.be")
129+
user = UlbCasBackend().authenticate(None, ticket=TICKET)
130+
131+
assert user.netid == "fant0001"
132+
assert user.email == "fantasio@ulb.be"
133+
assert User.objects.count() == 1
134+
135+
136+
@responses.activate
137+
def test_no_match_creates_user(fake_base_url):
138+
"""When neither netid nor email match, create a new user."""
139+
_mock_cas_response("mleblanc", "modeste.leblanc@ulb.be", "Modeste", "Leblanc")
140+
user = UlbCasBackend().authenticate(None, ticket=TICKET)
141+
142+
assert user.netid == "mleblanc"
143+
assert user.email == "modeste.leblanc@ulb.be"
144+
assert user.first_name == "Modeste"
145+
assert user.last_name == "Leblanc"
146+
assert User.objects.count() == 1
147+
148+
149+
@responses.activate
150+
def test_fields_synced_on_login(fake_base_url):
151+
"""All CAS fields are updated on every login."""
152+
User.objects.create_user(
153+
netid="pdemousk",
154+
email="prunelle.de.mouskinson@ulb.ac.be",
155+
first_name="Leon",
156+
last_name="Prunelle",
157+
)
158+
159+
_mock_cas_response(
160+
"pdemousk", "prunelle.de.mouskinson@ulb.be", "Leon", "De Mouskinson"
161+
)
162+
user = UlbCasBackend().authenticate(None, ticket=TICKET)
163+
164+
assert user.email == "prunelle.de.mouskinson@ulb.be"
165+
assert user.first_name == "Leon"
166+
assert user.last_name == "De Mouskinson"
167+
168+
169+
@responses.activate
170+
def test_no_duplicate_when_netid_and_email_match_different_users(fake_base_url):
171+
"""When netid matches user A and email matches user B, prefer user A (netid)."""
172+
User.objects.create_user(
173+
netid="glagaff", email="gaston.lagaffe@ulb.ac.be", first_name="Gaston"
174+
)
175+
User.objects.create_user(
176+
netid="lechat", email="gaston.lagaffe@ulb.be", first_name="Le Chat"
177+
)
178+
179+
_mock_cas_response("glagaff", "gaston.lagaffe@ulb.be")
180+
user = UlbCasBackend().authenticate(None, ticket=TICKET)
181+
182+
assert user.netid == "glagaff"
183+
# Email not updated because Le Chat already has it
184+
assert user.email == "gaston.lagaffe@ulb.ac.be"
185+
assert User.objects.count() == 2
186+
187+
# An EMAIL_CONFLICT failure should be logged
188+
failure = CasFailure.objects.get()
189+
assert failure.code == "EMAIL_CONFLICT"
190+
assert "glagaff" in failure.details
191+
assert "lechat" in failure.details

0 commit comments

Comments
 (0)