From aab3a418ac9293bb4abd7670f65d930cb0426d58 Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Wed, 28 Jun 2006 16:37:02 +0000 Subject: [PATCH] Merged multi-auth branch to trunk. See the authentication docs for the ramifications of this change. Many, many thanks to Joseph Kocherhans for the hard work! git-svn-id: http://code.djangoproject.com/svn/django/trunk@3226 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/conf/global_settings.py | 6 ++ django/contrib/admin/views/decorators.py | 16 ++-- django/contrib/auth/__init__.py | 69 ++++++++++++++ django/contrib/auth/backends.py | 21 +++++ django/contrib/auth/forms.py | 16 ++-- django/contrib/auth/middleware.py | 8 +- django/contrib/auth/models.py | 23 +++-- django/contrib/auth/views.py | 7 +- django/contrib/comments/views/comments.py | 4 +- docs/authentication.txt | 105 ++++++++++++++++++++-- 10 files changed, 229 insertions(+), 46 deletions(-) create mode 100644 django/contrib/auth/backends.py diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 640b290a6f..a1dff3c815 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -281,3 +281,9 @@ COMMENTS_FIRST_FEW = 0 # A tuple of IP addresses that have been banned from participating in various # Django-powered features. BANNED_IPS = () + +################## +# AUTHENTICATION # +################## + +AUTHENTICATION_BACKENDS = ('django.contrib.auth.backends.ModelBackend',) diff --git a/django/contrib/admin/views/decorators.py b/django/contrib/admin/views/decorators.py index d984077dfb..250c585220 100644 --- a/django/contrib/admin/views/decorators.py +++ b/django/contrib/admin/views/decorators.py @@ -1,6 +1,7 @@ from django import http, template from django.conf import settings -from django.contrib.auth.models import User, SESSION_KEY +from django.contrib.auth.models import User +from django.contrib.auth import authenticate, login from django.shortcuts import render_to_response from django.utils.translation import gettext_lazy import base64, datetime, md5 @@ -69,10 +70,10 @@ def staff_member_required(view_func): return _display_login_form(request, message) # Check the password. - username = request.POST.get('username', '') - try: - user = User.objects.get(username=username, is_staff=True) - except User.DoesNotExist: + username = request.POST.get('username', None) + password = request.POST.get('password', None) + user = authenticate(username=username, password=password) + if user is None: message = ERROR_MESSAGE if '@' in username: # Mistakenly entered e-mail address instead of username? Look it up. @@ -86,8 +87,9 @@ def staff_member_required(view_func): # The user data is correct; log in the user in and continue. else: - if user.check_password(request.POST.get('password', '')): - request.session[SESSION_KEY] = user.id + if user.is_staff: + login(request, user) + # TODO: set last_login with an event. user.last_login = datetime.datetime.now() user.save() if request.POST.has_key('post_data'): diff --git a/django/contrib/auth/__init__.py b/django/contrib/auth/__init__.py index ac7b40aca6..dde7ea5c9c 100644 --- a/django/contrib/auth/__init__.py +++ b/django/contrib/auth/__init__.py @@ -1,2 +1,71 @@ +from django.core.exceptions import ImproperlyConfigured + +SESSION_KEY = '_auth_user_id' +BACKEND_SESSION_KEY = '_auth_user_backend' LOGIN_URL = '/accounts/login/' REDIRECT_FIELD_NAME = 'next' + +def load_backend(path): + i = path.rfind('.') + module, attr = path[:i], path[i+1:] + try: + mod = __import__(module, '', '', [attr]) + except ImportError, e: + raise ImproperlyConfigured, 'Error importing authentication backend %s: "%s"' % (module, e) + try: + cls = getattr(mod, attr) + except AttributeError: + raise ImproperlyConfigured, 'Module "%s" does not define a "%s" authentication backend' % (module, attr) + return cls() + +def get_backends(): + from django.conf import settings + backends = [] + for backend_path in settings.AUTHENTICATION_BACKENDS: + backends.append(load_backend(backend_path)) + return backends + +def authenticate(**credentials): + """ + If the given credentials, return a user object. + """ + for backend in get_backends(): + try: + user = backend.authenticate(**credentials) + except TypeError: + # this backend doesn't accept these credentials as arguments, try the next one. + continue + if user is None: + continue + # annotate the user object with the path of the backend + user.backend = str(backend.__class__) + return user + +def login(request, user): + """ + Persist a user id and a backend in the request. This way a user doesn't + have to reauthenticate on every request. + """ + if user is None: + user = request.user + # TODO: It would be nice to support different login methods, like signed cookies. + request.session[SESSION_KEY] = user.id + request.session[BACKEND_SESSION_KEY] = user.backend + +def logout(request): + """ + Remove the authenticated user's id from request. + """ + del request.session[SESSION_KEY] + del request.session[BACKEND_SESSION_KEY] + +def get_user(request): + from django.contrib.auth.models import AnonymousUser + try: + user_id = request.session[SESSION_KEY] + backend_path = request.session[BACKEND_SESSION_KEY] + backend = load_backend(backend_path) + user = backend.get_user(user_id) or AnonymousUser() + except KeyError: + user = AnonymousUser() + return user diff --git a/django/contrib/auth/backends.py b/django/contrib/auth/backends.py new file mode 100644 index 0000000000..3b46b65b0a --- /dev/null +++ b/django/contrib/auth/backends.py @@ -0,0 +1,21 @@ +from django.contrib.auth.models import User, check_password + +class ModelBackend: + """ + Authenticate against django.contrib.auth.models.User + """ + # TODO: Model, login attribute name and password attribute name should be + # configurable. + def authenticate(self, username=None, password=None): + try: + user = User.objects.get(username=username) + if user.check_password(password): + return user + except User.DoesNotExist: + return None + + def get_user(self, user_id): + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist: + return None diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py index 800c14375b..ef81268e2a 100644 --- a/django/contrib/auth/forms.py +++ b/django/contrib/auth/forms.py @@ -1,4 +1,5 @@ from django.contrib.auth.models import User +from django.contrib.auth import authenticate from django.contrib.sites.models import Site from django.template import Context, loader from django.core import validators @@ -20,8 +21,7 @@ class AuthenticationForm(forms.Manipulator): self.fields = [ forms.TextField(field_name="username", length=15, maxlength=30, is_required=True, validator_list=[self.isValidUser, self.hasCookiesEnabled]), - forms.PasswordField(field_name="password", length=15, maxlength=30, is_required=True, - validator_list=[self.isValidPasswordForUser]), + forms.PasswordField(field_name="password", length=15, maxlength=30, is_required=True), ] self.user_cache = None @@ -30,16 +30,10 @@ class AuthenticationForm(forms.Manipulator): raise validators.ValidationError, _("Your Web browser doesn't appear to have cookies enabled. Cookies are required for logging in.") def isValidUser(self, field_data, all_data): - try: - self.user_cache = User.objects.get(username=field_data) - except User.DoesNotExist: - raise validators.ValidationError, _("Please enter a correct username and password. Note that both fields are case-sensitive.") - - def isValidPasswordForUser(self, field_data, all_data): + username = field_data + password = all_data.get('password', None) + self.user_cache = authenticate(username=username, password=password) if self.user_cache is None: - return - if not self.user_cache.check_password(field_data): - self.user_cache = None raise validators.ValidationError, _("Please enter a correct username and password. Note that both fields are case-sensitive.") elif not self.user_cache.is_active: raise validators.ValidationError, _("This account is inactive.") diff --git a/django/contrib/auth/middleware.py b/django/contrib/auth/middleware.py index a1a0b2e834..a6a60780a7 100644 --- a/django/contrib/auth/middleware.py +++ b/django/contrib/auth/middleware.py @@ -4,12 +4,8 @@ class LazyUser(object): def __get__(self, request, obj_type=None): if self._user is None: - from django.contrib.auth.models import User, AnonymousUser, SESSION_KEY - try: - user_id = request.session[SESSION_KEY] - self._user = User.objects.get(pk=user_id) - except (KeyError, User.DoesNotExist): - self._user = AnonymousUser() + from django.contrib.auth import get_user + self._user = get_user(request) return self._user class AuthenticationMiddleware(object): diff --git a/django/contrib/auth/models.py b/django/contrib/auth/models.py index 65392695ae..e37f5a4497 100644 --- a/django/contrib/auth/models.py +++ b/django/contrib/auth/models.py @@ -4,7 +4,19 @@ from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ import datetime -SESSION_KEY = '_auth_user_id' +def check_password(raw_password, enc_password): + """ + Returns a boolean of whether the raw_password was correct. Handles + encryption formats behind the scenes. + """ + algo, salt, hsh = enc_password.split('$') + if algo == 'md5': + import md5 + return hsh == md5.new(salt+raw_password).hexdigest() + elif algo == 'sha1': + import sha + return hsh == sha.new(salt+raw_password).hexdigest() + raise ValueError, "Got unknown password algorithm type in password." class SiteProfileNotAvailable(Exception): pass @@ -141,14 +153,7 @@ class User(models.Model): self.set_password(raw_password) self.save() return is_correct - algo, salt, hsh = self.password.split('$') - if algo == 'md5': - import md5 - return hsh == md5.new(salt+raw_password).hexdigest() - elif algo == 'sha1': - import sha - return hsh == sha.new(salt+raw_password).hexdigest() - raise ValueError, "Got unknown password algorithm type in password." + return check_password(raw_password, self.password) def get_group_permissions(self): "Returns a list of permission strings that this user has through his/her groups." diff --git a/django/contrib/auth/views.py b/django/contrib/auth/views.py index 6d908ee025..97ecd6a3fd 100644 --- a/django/contrib/auth/views.py +++ b/django/contrib/auth/views.py @@ -3,7 +3,6 @@ from django.contrib.auth.forms import PasswordResetForm, PasswordChangeForm from django import forms from django.shortcuts import render_to_response from django.template import RequestContext -from django.contrib.auth.models import SESSION_KEY from django.contrib.sites.models import Site from django.http import HttpResponse, HttpResponseRedirect from django.contrib.auth.decorators import login_required @@ -19,7 +18,8 @@ def login(request, template_name='registration/login.html'): # Light security check -- make sure redirect_to isn't garbage. if not redirect_to or '://' in redirect_to or ' ' in redirect_to: redirect_to = '/accounts/profile/' - request.session[SESSION_KEY] = manipulator.get_user_id() + from django.contrib.auth import login + login(request, manipulator.get_user()) request.session.delete_test_cookie() return HttpResponseRedirect(redirect_to) else: @@ -33,8 +33,9 @@ def login(request, template_name='registration/login.html'): def logout(request, next_page=None, template_name='registration/logged_out.html'): "Logs out the user and displays 'You are logged out' message." + from django.contrib.auth import logout try: - del request.session[SESSION_KEY] + logout(request) except KeyError: return render_to_response(template_name, {'title': _('Logged out')}, context_instance=RequestContext(request)) else: diff --git a/django/contrib/comments/views/comments.py b/django/contrib/comments/views/comments.py index 316f3e719b..c32a82f4d8 100644 --- a/django/contrib/comments/views/comments.py +++ b/django/contrib/comments/views/comments.py @@ -5,7 +5,6 @@ from django.http import Http404 from django.core.exceptions import ObjectDoesNotExist from django.shortcuts import render_to_response from django.template import RequestContext -from django.contrib.auth.models import SESSION_KEY from django.contrib.comments.models import Comment, FreeComment, PHOTOS_REQUIRED, PHOTOS_OPTIONAL, RATINGS_REQUIRED, RATINGS_OPTIONAL, IS_PUBLIC from django.contrib.contenttypes.models import ContentType from django.contrib.auth.forms import AuthenticationForm @@ -219,7 +218,8 @@ def post_comment(request): # If user gave correct username/password and wasn't already logged in, log them in # so they don't have to enter a username/password again. if manipulator.get_user() and new_data.has_key('password') and manipulator.get_user().check_password(new_data['password']): - request.session[SESSION_KEY] = manipulator.get_user_id() + from django.contrib.auth import login + login(request, manipulator.get_user()) if errors or request.POST.has_key('preview'): class CommentFormWrapper(forms.FormWrapper): def __init__(self, manipulator, new_data, errors, rating_choices): diff --git a/docs/authentication.txt b/docs/authentication.txt index 79a4ed0875..3edbc21f7a 100644 --- a/docs/authentication.txt +++ b/docs/authentication.txt @@ -267,17 +267,25 @@ previous section). You can tell them apart with ``is_anonymous()``, like so:: How to log a user in -------------------- -To log a user in, do the following within a view:: +Depending on your task, you'll probably want to make sure to validate the +user's username and password before you log them in. The easiest way to do so +is to use the built-in ``authenticate`` and ``login`` functions from within a +view:: - from django.contrib.auth.models import SESSION_KEY - request.session[SESSION_KEY] = some_user.id + from django.contrib.auth import authenticate, login + username = request.POST['username'] + password = request.POST['password'] + user = authenticate(username=username, password=password) + if user is not None: + login(request, user) -Because this uses sessions, you'll need to make sure you have -``SessionMiddleware`` enabled. See the `session documentation`_ for more -information. +``authenticate`` checks the username and password. If they are valid it +returns a user object, otherwise it returns ``None``. ``login`` makes it so +your users don't have send a username and password for every request. Because +the ``login`` function uses sessions, you'll need to make sure you have +``SessionMiddleware`` enabled. See the `session documentation`_ for +more information. -This assumes ``some_user`` is your ``User`` instance. Depending on your task, -you'll probably want to make sure to validate the user's username and password. Limiting access to logged-in users ---------------------------------- @@ -672,3 +680,84 @@ Finally, note that this messages framework only works with users in the user database. To send messages to anonymous users, use the `session framework`_. .. _session framework: http://www.djangoproject.com/documentation/sessions/ + +Other Authentication Sources +============================ + +Django supports other authentication sources as well. You can even use +multiple sources at the same time. + +Using multiple backends +----------------------- + +The list of backends to use is controlled by the ``AUTHENTICATION_BACKENDS`` +setting. This should be a tuple of python path names. It defaults to +``('django.contrib.auth.backends.ModelBackend',)``. To add additional backends +just add them to your settings.py file. Ordering matters, so if the same +username and password is valid in multiple backends, the first one in the +list will return a user object, and the remaining ones won't even get a chance. + +Writing an authentication backend +--------------------------------- + +An authentication backend is a class that implements 2 methods: +``get_user(id)`` and ``authenticate(**credentials)``. The ``get_user`` method +takes an id, which could be a username, and database id, whatever, and returns +a user object. The ``authenticate`` method takes credentials as keyword +arguments. Many times it will just look like this:: + + class MyBackend: + def authenticate(username=None, password=None): + # check the username/password and return a user + +but it could also authenticate a token like so:: + + class MyBackend: + def authenticate(token=None): + # check the token and return a user + +Regardless, ``authenticate`` should check the credentials it gets, and if they +are valid, it should return a user object that matches those credentials. + +The Django admin system is tightly coupled to the Django User object described +at the beginning of this document. For now, the best way to deal with this is +to create a Django User object for each user that exists for your backend +(i.e. in your LDAP directory, your external SQL database, etc.) You can either +write a script to do this in advance, or your ``authenticate`` method can do +it the first time a user logs in. Here's an example backend that +authenticates against a username and password variable defined in your +``settings.py`` file and creates a Django user object the first time they +authenticate:: + + from django.conf import settings + from django.contrib.auth.models import User, check_password + + class SettingsBackend: + """ + Authenticate against vars in settings.py Use the login name, and a hash + of the password. For example: + + ADMIN_LOGIN = 'admin' + ADMIN_PASSWORD = 'sha1$4e987$afbcf42e21bd417fb71db8c66b321e9fc33051de' + """ + def authenticate(self, username=None, password=None): + login_valid = (settings.ADMIN_LOGIN == username) + pwd_valid = check_password(password, settings.ADMIN_PASSWORD) + if login_valid and pwd_valid: + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + # Create a new user. Note that we can set password to anything + # as it won't be checked, the password from settings.py will. + user = User(username=username, password='get from settings.py') + user.is_staff = True + user.is_superuser = True + user.save() + return user + return None + + def get_user(self, user_id): + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist: + return None