Authentication when usernames are not unique

337 views
Skip to first unread message

Erik Cederstrand

unread,
Jan 19, 2015, 3:55:22 PM1/19/15
to Django Users
Hello

I'm creating a Django frontend for a legacy school system. The legacy system has users, but usernames are only unique together with a school:

class LegacyUser(models.Model):
school = models.ForeignKey(School)
username = models.CharField(max_length=40)

class Meta:
unique_together = ['school', 'username']


I need to authenticate users using their school name, username and password, so I can serve them data connected to the LegacyUser. The legacy system provides an authentication service that I want to use to verify the password.

The Django authentication model seems to revolve around the username being unique, so I can't just inherit the User model, login forms etc. How do I get the School shoe-horned into the Django auth framework, and where do I call the external authentication service? Some ideas how to best accomplish this would be great!


Thanks,
Erik

Stephen J. Butler

unread,
Jan 19, 2015, 4:46:40 PM1/19/15
to django...@googlegroups.com
Just as a case study, Shibboleth does this by having unscoped and
scoped usernames. The scoped username should be globally unique and
takes the form of "us...@school1.edu". Unscopped is not globally
unique, but unique for a particular scope (ie: "user").

It's temping to say "ahh... email address!" But in Shibboleth
terminology that would be a mistake. A single user (us...@school1.edu)
could have multiple email addresses (us...@school1.edu,
us...@dept.school1.edu, user...@school.edu, etc). The scoped user
identifies the user independently of their emails, even though the
value might be the same.

Generally the scope part comes from the ID provider (the thing doing
the authentication and providing user attributes) and not the mail
system. So the ID provider could be an AD domain, for instance. As
long as it's unique.
> --
> You received this message because you are subscribed to the Google Groups "Django users" group.
> To unsubscribe from this group and stop receiving emails from it, send an email to django-users...@googlegroups.com.
> To post to this group, send email to django...@googlegroups.com.
> Visit this group at http://groups.google.com/group/django-users.
> To view this discussion on the web visit https://groups.google.com/d/msgid/django-users/26BA41BB-1771-4C5C-9980-A2C49F30280C%40cederstrand.dk.
> For more options, visit https://groups.google.com/d/optout.

James Schneider

unread,
Jan 19, 2015, 4:48:37 PM1/19/15
to django...@googlegroups.com
That's an interesting (but understandable) requirement, which means you are probably in for an interesting time.

Undoubtedly you'll need to roll your own authentication backend (https://docs.djangoproject.com/en/1.7/topics/auth/customizing/#authentication-backends) and a custom user that contains the campus as one of the attributes (https://docs.djangoproject.com/en/1.7/topics/auth/customizing/#substituting-a-custom-user-model).

As far as the user model inheritance goes, I would recommend inheriting from AbstractBaseUser (https://github.com/django/django/blob/master/django/contrib/auth/models.py#L196) and probably the Permission mixin in that same file if you are using the Django authorization system. Also take a look at the AbstractUser class for hints on the other bits you may need to manually specify that are not included in AbstractBaseUser.

However, as you've aptly pointed out, the (somewhat) tricky part is that your authentication system requires a third piece of data that Django is not expecting, the campus name.

When you begin overriding/implementing the various bits of the authentication system, you'll likely need to add an additional keyword argument like 'campus' or some other identifier such as: 

def authenticate(self, username=None, password=None, campus=None):
    # do auth things related to campus


Then, any views, etc. that would run the contrib.auth's version of authenticate() would instead call your version with the extended signature including the campus. A quick search through the Github repo suggests that the only place where authenticate() is called is via the built-in form/view for logging in using the default authentication backend. You would have to override both of these constructs as part of the custom auth backend anyway, so you appear to be in luck. Your authentication call would look something like authenticate(user, pass, campus).

You didn't mention anything about how each campus handles authentication (doesn't matter for this conversation), but your authenticate() method would likely contain a dictionary of methods that handle the actual interaction between Django and the specific campus backend, keyed via the campus name (you could also put this in settings.py and import it):

def _auth_campus1(self, user, pass):
    # stuff for campus 1

def _auth_campus2(self, user, pass):
    # stuff for campus 2


def authenticate(self, user,pass,campus):
    AUTH_SOURCES = {
        'campus1': _auth_campus1,
        'campus2: _auth_campus2,
    }

    if campus not in AUTH_SOURCES:
        return None

    auth_func = AUTH_SOURCES[campus]

    if auth_func(user, pass):
        # check if user exists in local Django DB, otherwise create_user()
 
    # other checks
    # return user or None



Once you get past the authentication portion, it looks as though the authorization portion should work out of the box (assuming the other bits are overridden properly per the docs), since you already have a user object, if you plan to use it at all.

Be sure to have sane timeouts so that users aren't stuck waiting for broken campus backends, and so that processes/threads aren't stalled when they could be serving other users.

HTH,

-James


James Schneider

unread,
Jan 19, 2015, 4:57:49 PM1/19/15
to django...@googlegroups.com

Hmm, yes, Shibboleth will require some extra trickery in multiple views with redirects to the respective campus portal, etc. I've never done it myself, but I believe the API is pretty well documented.

-James

Stephen J. Butler

unread,
Jan 19, 2015, 5:06:37 PM1/19/15
to django...@googlegroups.com
Shibboleth 2.0 lets you setup a discovery service (or portal would
perhaps be a better term) letting the user select which ID Provider
(IdP) they will authenticate to. All you have to do on the Service
Provider (SP) side is specify the discovery URL and what IdPs you
allow. Nothing needs to be done in your Django app except support
Shibboleth.

Of course, this is all predicated on there being a competent
Shibboleth setup at your institutions.

James Schneider

unread,
Jan 19, 2015, 5:24:56 PM1/19/15
to django...@googlegroups.com
For a pure authentication scenario where permission checks never go beyond user.is_authenticated(), that's probably true. If all the OP is doing is displaying data, they may be able to get away with manually associating the campus and user within the session after, and displaying data based on those session keys. Basically you would end up with a boolean layer of protection for each resource, because all you know is the validated username and campus pair. That may work just fine.

However, if you need any sort of authorization (permission checking) within the app using Django's permission system, you'll probably need a local copy of the user using a custom user model in the database to perform checks against. It sounds like the OP may need that. Otherwise you are also looking at rolling a custom authorization backend as well.

If they are LDAP services, you can look at django-ldap, which works quite nicely, including group membership restrictions. It also does the overriding of the authentication backend for you. Not sure how it would work with multiple LDAP servers for various campuses though. That would need some research.

TL;DR; There are a lot of ways to slice this problem, and a primary strategy driver will be the available authentication backends at each campus. Hopefully they are all the same.

-James


--
You received this message because you are subscribed to the Google Groups "Django users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to django-users...@googlegroups.com.
To post to this group, send email to django...@googlegroups.com.
Visit this group at http://groups.google.com/group/django-users.

Erik Cederstrand

unread,
Jan 20, 2015, 1:54:45 AM1/20/15
to Django Users
Hi guys,

Thanks for a lot of useful answers! My schools use a palette of authentication systems; regular hashed password check with a hash I have access to, LDAP auth and WAYF (a Danish educational SSO solution using Shibboleth). Those are the least of my worries right now, though.

I'll have a look at the AbstractBaseUser and friends. Ideally, I'd like to expand on my existing LegacyUser model and avoid creating separate Django users that shadow the LegacyUser, as I do a lot of synchronization of LegacyUsers with the legacy system. I'll see how it goes and post a solution if I get that far.

Erik
> To view this discussion on the web visit https://groups.google.com/d/msgid/django-users/CA%2Be%2BciXW3NoDaTTpaiL2qtD69vkjmSEFfGSeM9-Dk00YMAG6NQ%40mail.gmail.com.

James Schneider

unread,
Jan 20, 2015, 2:32:28 AM1/20/15
to django...@googlegroups.com

"A pallet of authentication systems..."

Yep, you work for an educational entity, as do I. :-D

You can pursue the LegacyUser model as your custom user model. That would make your LegacyUser objects the 'local' Django users that have been referenced. Not sure about the name though, might indicate that there are CurrentUsers floating about the system.

Another option is to rename LegacyUser to something like AppUser, and then add an extra user_type field to the model for filtering/identification.

Or if you only have one type of user, you probably don't need to worry about it.

Keep us in the loop, I'd be interested in a high-level solution.

-James

James Schneider

unread,
Jan 20, 2015, 2:36:10 AM1/20/15
to django...@googlegroups.com

BTW, I reread you're last note. The references to AbstractBaseUser were meant for inheritance, meaning that your custom user would inherit all of the same properties and not need to redefine the wheel. You can't inherit from AbstractUser though because you are modifying the username field.

-James

Erik Cederstrand

unread,
Jan 20, 2015, 6:18:13 PM1/20/15
to Django Users
Ok, here's a stripped-down solution.

I ended up creating a new SchoolUser user model with a OneToOne relation to my LegacyUser, to keep the LegacyUser model uncluttered. The SchoolUser implements all methods from AbstractBaseUser and PermissionsMixin but doesn't inherit from them, because I don't want the model fields that they contain.

I also kept the SchoolUser independent from the standard Django User (i.e. no AUTH_USER_MODEL='SchoolUser' in settings.py), so I can still create superuser accounts for myself and my colleagues, that are not connected to a school user.

Here's the code:


settings.py:
[...]
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',
'school_auth.backends.SchoolModelBackend',
)


school_auth/backend.py:
from django.contrib.auth.backends import ModelBackend
from .models import SchoolUser, LegacyUser
class SchoolModelBackend(object):
def authenticate(self, school_id=None, username=None, password=None, **kwargs):
if LegacyUser.validate(school=school_id, username=username, password=password):
# Password hash validation
try:
school_user = SchoolUser.objects.get(user__school=school_id, user__name=username)
except SchoolUser.DoesNotExist:
school_user = SchoolUser.objects.create_user(school_id=school_id, username=username)
# Annotate the user object with the path of the backend.
school_user.backend = "%s.%s" % (self.__class__.__module__, self.__class__.__name__)
return school_user
#
# if LDAP.validate(school=school_id, username=username, password=password):
# pass
# if WAYF.validate(school=school_id, username=username, password=password):
# pass
return None

def get_group_permissions(self, user_obj, obj=None):
raise NotImplementedError()
    def get_all_permissions(self, user_obj, obj=None):
raise NotImplementedError()

def has_perm(self, user_obj, perm, obj=None):
if not user_obj.is_active:
return False
return perm in self.get_all_permissions(user_obj, obj)

def has_module_perms(self, user_obj, app_label):
if not user_obj.is_active:
return False
for perm in self.get_all_permissions(user_obj):
if perm[:perm.index('.')] == app_label:
return True
return False

def get_user(self, user_id):
try:
return SchoolUser.objects.get(pk=user_id)
except SchoolUser.DoesNotExist:
return None


school_auth/forms.py:
from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm, PasswordChangeForm
from django import forms
from django.utils.translation import ugettext_lazy as _
from django.utils.text import capfirst
from .models import LegacyUser, School, SchoolUser
from .backends import SchoolModelBackend
class SchoolAuthenticationForm(AuthenticationForm):
school = forms.ModelChoiceField(queryset=School.objects.active(), empty_label=_('Please select a school'))
username = forms.CharField(max_length=40)
password = forms.CharField(label=_("Password"), widget=forms.PasswordInput)

class Meta:
model = LegacyUser
        fields = ['school', 'name', 'password']

def __init__(self, request=None, *args, **kwargs):
"""
The 'request' parameter is set for custom auth use by subclasses.
The form data comes in via the standard 'data' kwarg.
"""
self.request = request
self.user_cache = None
super().__init__(*args, **kwargs)

# Set the label for the "username" field.
self.username_field = LegacyUser._meta.get_field(SchoolUser.USERNAME_FIELD)
if self.fields['username'].label is None:
self.fields['username'].label = capfirst(self.username_field.verbose_name)

def clean(self):
school = self.cleaned_data.get('school')
username = self.cleaned_data.get('username')
password = self.cleaned_data.get('password')

if school and username and password:
self.user_cache = SchoolModelBackend().authenticate(school_id=school.pk, username=username,
password=password)
if self.user_cache is None:
raise forms.ValidationError(
self.error_messages['invalid_login'],
code='invalid_login',
params={'username': self.username_field.verbose_name},
)
else:
self.confirm_login_allowed(self.user_cache)

return self.cleaned_data


school_auth/models.py:
from django.contrib.auth.models import PermissionsMixin, BaseUserManager, AbstractBaseUser
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from django.utils.crypto import salted_hmac
from ..legacy.models import LegacyUser, School
class SchoolUserManager(BaseUserManager):
    def create_user(self, school_id, username):
user = LegacyUser.objects.get(school=school_id, name=username)
school_user = self.model(user=user)
school_user.save()
return school_user


class SchoolUser(models.Model):
"""
Custom User model. We don't inherit AbstractBaseUser because we don't want the password field.
"""
USERNAME_FIELD = 'name'

user = models.OneToOneField(User, null=False)
last_login = models.DateTimeField(_('last login'), default=timezone.now)

objects = SchoolUserManager()
REQUIRED_FIELDS = []

@property
def school(self):
return self.user.school

def __str__(self):
return self.get_username()

def get_username(self):
return self.user.name

@property
def is_active(self):
return self.user.active

@property
def is_staff(self):
return self.user.is_admin()

def natural_key(self):
return self.user.school_id, self.user.name

@staticmethod
def is_anonymous():
return False

@staticmethod
def is_authenticated():
return True

def set_password(self, raw_password):
self.user.set_password(raw_password)

def check_password(self, raw_password):
return self.user.validate_password(raw_password)

def set_unusable_password(self):
pass

@staticmethod
def has_usable_password():
return True

def get_full_name(self):
return self.user.name

def get_short_name(self):
return self.user.name

def get_session_auth_hash(self):
"""
Returns an HMAC of the password field.
"""
key_salt = "django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash"
return salted_hmac(key_salt, self.user.password).hexdigest()

def email_user(self, subject, message, from_email=None, **kwargs):
"""
Sends an email to this User.
"""
from django.core.mail import send_mail
send_mail(subject, message, from_email, [self.email], **kwargs)

@property
def is_superuser(self):
# This can never be a Django superuser
return False
    def get_group_permissions(self, obj=None):
raise NotImplementedError()
    def get_all_permissions(self, obj=None):
raise NotImplementedError()

def has_perm(self, perm, obj=None):
[...] # My custom per-app permissions

def has_perms(self, perm_list, obj=None):
for perm in perm_list:
if not self.has_perm(perm, obj):
return False
return True

def has_module_perms(self, app_label):
[...] # My custom per-module permissions



school_auth/urls.py:
from django.conf.urls import url
from django.contrib.auth.views import login, logout
from .forms import SchoolAuthenticationForm
urlpatterns = [
url(r'^login/$', login, kwargs={'authentication_form': SchoolAuthenticationForm, 'template_name': 'school_auth/login.html'}, name='school_auth.login'),
url(r'^logout/$', logout, name='school_auth.logout'),
]

Thanks,
Erik

James Schneider

unread,
Jan 20, 2015, 6:24:46 PM1/20/15
to django...@googlegroups.com

Wow, nice. You are probably right in not inheriting from PermissionMixin and AbstractBaseUser and just re-implementing the needed functionality. I ended up doing the same thing for my custom user and auth backend as well. Not as convenient, but hey, it works right? ;-)

Bravo.

-James

--
You received this message because you are subscribed to the Google Groups "Django users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to django-users...@googlegroups.com.
To post to this group, send email to django...@googlegroups.com.
Visit this group at http://groups.google.com/group/django-users.
Reply all
Reply to author
Forward
0 new messages