diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fb7b36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc +MANIFEST +dist/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d6d9ba --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +Django Email as Username +======================== + +**User authentication with email addresses instead of usernames.** + +**Author:** Tom Christie, [@_tomchristie][1]. + +**See also:** [django-email-login][2], [django-email-usernames][3]. + +Overview +======== + +Allows you to treat users as having only email addresses, instead of usernames. + +1. Provides an email auth backend and helper functions for creating users. +2. Patches the Django admin to handle email based user authentication. +3. Overides the `createsuperuser` command to create users with email only. +4. Treats email authentication as case-insensitive. + +Installation +============ + +Install from PyPI: + + pip install django-email-as-username + +Add 'emailusernames' to INSTALLED_APPS. + + INSTALLED_APPS = ( + ... + 'emailusernames', + ) + +Set `EmailAuthBackend` as your authentication backend: + + AUTHENTICATION_BACKENDS = ( + 'emailusernames.backends.EmailAuthBackend', + ) + +Usage +===== + +Creating users +-------------- + +You should create users using the `create_user` and `create_superuser` +functions. + + from emailusernames.utils import create_user, create_superuser + + create_user('me@example.com', 'password') + create_superuser('admin@example.com', 'password') + +Retrieving users +---------------- + +You can retrieve users, using case-insensitive email matching, with the +`get_user` function. Similarly you can use `user_exists` to test if a given +user exists. + + from emailusernames.utils import get_user, user_exists + + user = get_user('someone@example.com') + ... + + if user_exists('someone@example.com'): + ... + +Updating users +-------------- + +You can update a user's email and save the instance, without having to also modify the username. + + user.email = 'other@example.com' + user.save() + +Note that the `user.username` attribute will always return the email address, but behind the scenes it will be stored as a hashed version of the user's email. + +User Forms +---------- + +`emailusernames` provides the following several forms that you can use for authenticating, creating and updating users: + +* `emailusernames.forms.EmailAuthenticationForm` +* `emailusernames.forms.EmailAdminAuthenticationForm` +* `emailusernames.forms.UserCreationForm` +* `emailusernames.forms.UserChangeForm` + +License +======= + +Copyright © 2012, DabApps. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +[1]: http://twitter.com/_tomchristie +[2]: https://bitbucket.org/tino/django-email-login +[3]: https://bitbucket.org/hakanw/django-email-usernames \ No newline at end of file diff --git a/emailusernames/__init__.py b/emailusernames/__init__.py new file mode 100644 index 0000000..b794fd4 --- /dev/null +++ b/emailusernames/__init__.py @@ -0,0 +1 @@ +__version__ = '0.1.0' diff --git a/emailusernames/admin.py b/emailusernames/admin.py new file mode 100644 index 0000000..11a3efa --- /dev/null +++ b/emailusernames/admin.py @@ -0,0 +1,36 @@ +""" +Override the add- and change-form in the admin, to hide the username. +""" +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import User +from django.contrib import admin +from emailusernames.forms import EmailUserCreationForm, EmailUserChangeForm +from django.utils.translation import ugettext_lazy as _ + + +class EmailLoginAdmin(UserAdmin): + add_form = EmailUserCreationForm + form = EmailUserChangeForm + + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password1', 'password2')} + ), + ) + fieldsets = ( + (None, {'fields': ('email', 'password')}), + (_('Personal info'), {'fields': ('first_name', 'last_name')}), + (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', 'user_permissions')}), + (_('Important dates'), {'fields': ('last_login', 'date_joined')}), + (_('Groups'), {'fields': ('groups',)}), + ) + list_display = ('email', 'first_name', 'last_name', 'is_staff') + + +admin.site.unregister(User) +admin.site.register(User, EmailLoginAdmin) + + +def __email_unicode__(self): + return self.email diff --git a/emailusernames/backends.py b/emailusernames/backends.py new file mode 100644 index 0000000..5be6a48 --- /dev/null +++ b/emailusernames/backends.py @@ -0,0 +1,25 @@ +from django.contrib.auth.models import User +from emailusernames.utils import get_user + + +class EmailAuthBackend(object): + + """Allow users to log in with their email address""" + + supports_inactive_user = False + supports_anonymous_user = False + supports_object_permissions = False + + def authenticate(self, email=None, password=None): + try: + user = get_user(email) + 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/emailusernames/forms.py b/emailusernames/forms.py new file mode 100644 index 0000000..c32477b --- /dev/null +++ b/emailusernames/forms.py @@ -0,0 +1,102 @@ +from django import forms +from django.contrib.auth import authenticate +from django.contrib.auth.forms import UserCreationForm, UserChangeForm, AuthenticationForm +from django.contrib.admin.forms import AdminAuthenticationForm +from django.contrib.auth.models import User +from django.utils.translation import ugettext_lazy as _ +from emailusernames.utils import user_exists + + +ERROR_MESSAGE = _("Please enter a correct email and password. ") +ERROR_MESSAGE_RESTRICTED = _("You do not have permission to access the admin.") +ERROR_MESSAGE_INACTIVE = _("This account is inactive.") + + +class EmailAuthenticationForm(AuthenticationForm): + """ + Override the default AuthenticationForm to force email-as-username behavior. + """ + email = forms.EmailField(label=_("Email"), max_length=70) + message_incorrect_password = ERROR_MESSAGE + message_inactive = ERROR_MESSAGE_INACTIVE + + def __init__(self, *args, **kwargs): + super(EmailAdminAuthenticationForm, self).__init__(*args, **kwargs) + del self.fields['username'] + + def clean(self): + email = self.cleaned_data.get('email') + password = self.cleaned_data.get('password') + + if email and password: + self.user_cache = authenticate(email=email, password=password) + if (self.user_cache is None): + raise forms.ValidationError(self.message_incorrect_password) + if not self.user_cache.is_active: + raise forms.ValidationError(self.message_inactive) + self.check_for_test_cookie() + return self.cleaned_data + + +class EmailAdminAuthenticationForm(AdminAuthenticationForm): + """ + Override the default AuthenticationForm to force email-as-username behavior. + """ + email = forms.EmailField(label=_("Email"), max_length=70) + message_incorrect_password = ERROR_MESSAGE + message_inactive = ERROR_MESSAGE_INACTIVE + message_restricted = ERROR_MESSAGE_RESTRICTED + + def __init__(self, *args, **kwargs): + super(EmailAdminAuthenticationForm, self).__init__(*args, **kwargs) + del self.fields['username'] + + def clean(self): + email = self.cleaned_data.get('email') + password = self.cleaned_data.get('password') + + if email and password: + self.user_cache = authenticate(email=email, password=password) + if (self.user_cache is None): + raise forms.ValidationError(self.message_incorrect_password) + if not self.user_cache.is_active: + raise forms.ValidationError(self.message_inactive) + if not self.user_cache.is_staff: + raise forms.ValidationError(self.message_restricted) + self.check_for_test_cookie() + return self.cleaned_data + + +class EmailUserCreationForm(UserCreationForm): + """ + Override the default UserCreationForm to force email-as-username behavior. + """ + email = forms.EmailField(label=_("Email"), max_length=70) + + class Meta: + model = User + fields = ("email",) + + def __init__(self, *args, **kwargs): + super(EmailUserCreationForm, self).__init__(*args, **kwargs) + del self.fields['username'] + + def clean_email(self): + email = self.cleaned_data["email"] + if user_exists(email): + raise forms.ValidationError(_("A user with that email already exists.")) + return email + + +class EmailUserChangeForm(UserChangeForm): + """ + Override the default UserChangeForm to force email-as-username behavior. + """ + email = forms.EmailField(label=_("Email"), max_length=70) + + class Meta: + model = User + + def __init__(self, *args, **kwargs): + super(EmailUserChangeForm, self).__init__(*args, **kwargs) + del self.fields['username'] diff --git a/emailusernames/management/__init__.py b/emailusernames/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emailusernames/management/commands/__init__.py b/emailusernames/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emailusernames/management/commands/createsuperuser.py b/emailusernames/management/commands/createsuperuser.py new file mode 100644 index 0000000..aebdaf8 --- /dev/null +++ b/emailusernames/management/commands/createsuperuser.py @@ -0,0 +1,102 @@ +""" +Management utility to create superusers. +Replace default behaviour to use emails as usernames. +""" + +import getpass +import re +import sys +from optparse import make_option +from django.contrib.auth.models import User +from django.core import exceptions +from django.core.management.base import BaseCommand, CommandError +from django.utils.translation import ugettext as _ +from email_as_username.utils import create_superuser + + +RE_VALID_USERNAME = re.compile('[\w.@+-]+$') + +EMAIL_RE = re.compile( + r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*" # dot-atom + r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-\011\013\014\016-\177])*"' # quoted-string + r')@(?:[A-Z0-9-]+\.)+[A-Z]{2,6}$', re.IGNORECASE) # domain + + +def is_valid_email(value): + if not EMAIL_RE.search(value): + raise exceptions.ValidationError(_('Enter a valid e-mail address.')) + + +class Command(BaseCommand): + option_list = BaseCommand.option_list + ( + make_option('--email', dest='email', default=None, + help='Specifies the email address for the superuser.'), + make_option('--noinput', action='store_false', dest='interactive', default=True, + help=('Tells Django to NOT prompt the user for input of any kind. ' + 'You must use --username and --email with --noinput, and ' + 'superusers created with --noinput will not be able to log ' + 'in until they\'re given a valid password.')), + ) + help = 'Used to create a superuser.' + + def handle(self, *args, **options): + email = options.get('email', None) + interactive = options.get('interactive') + verbosity = int(options.get('verbosity', 1)) + + # Do quick and dirty validation if --noinput + if not interactive: + if not email: + raise CommandError("You must use --email with --noinput.") + try: + is_valid_email(email) + except exceptions.ValidationError: + raise CommandError("Invalid email address.") + + # If not provided, create the user with an unusable password + password = None + + # Prompt for username/email/password. Enclose this whole thing in a + # try/except to trap for a keyboard interrupt and exit gracefully. + if interactive: + try: + # Get an email + while 1: + if not email: + email = raw_input('E-mail address: ') + + try: + is_valid_email(email) + except exceptions.ValidationError: + sys.stderr.write("Error: That e-mail address is invalid.\n") + email = None + + try: + User.objects.get(email__iexact=email) + except User.DoesNotExist: + break + else: + sys.stderr.write("Error: That email is already taken.\n") + email = None + + # Get a password + while 1: + if not password: + password = getpass.getpass() + password2 = getpass.getpass('Password (again): ') + if password != password2: + sys.stderr.write("Error: Your passwords didn't match.\n") + password = None + continue + if password.strip() == '': + sys.stderr.write("Error: Blank passwords aren't allowed.\n") + password = None + continue + break + except KeyboardInterrupt: + sys.stderr.write("\nOperation cancelled.\n") + sys.exit(1) + + create_superuser(email, password) + if verbosity >= 1: + self.stdout.write("Superuser created successfully.\n") diff --git a/emailusernames/models.py b/emailusernames/models.py new file mode 100644 index 0000000..1a81a7f --- /dev/null +++ b/emailusernames/models.py @@ -0,0 +1,28 @@ +from django.contrib.admin.sites import AdminSite +from django.contrib.auth.models import User +from emailusernames.forms import EmailAdminAuthenticationForm +from emailusernames.utils import _email_to_username + + +# Horrible monkey patching. +# User.username always presents as the email, but saves as a hash of the email. +# It would be possible to avoid such a deep level of monkey-patching, +# but Django's admin displays the "Welcome, username" using user.username, +# and there's really no other way to get around it. +def user_init_patch(self, *args, **kwargs): + super(User, self).__init__(*args, **kwargs) + self.username = self.email + + +def user_save_patch(self, *args, **kwargs): + self.username = _email_to_username(self.email) + super(User, self).save(*args, **kwargs) + self.username = self.email + + +User.__init__ = user_init_patch +User.save = user_save_patch + +# Monkey-path the admin site to use a custom login form +AdminSite.login_form = EmailAdminAuthenticationForm +AdminSite.login_template = 'email_usernames/login.html' diff --git a/emailusernames/templates/email_usernames/login.html b/emailusernames/templates/email_usernames/login.html new file mode 100644 index 0000000..14f41b6 --- /dev/null +++ b/emailusernames/templates/email_usernames/login.html @@ -0,0 +1,57 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block extrastyle %}{% load adminmedia %}{{ block.super }} + + +{% endblock %} + +{% block bodyclass %}login{% endblock %} + +{% block nav-global %}{% endblock %} + +{% block content_title %}{% endblock %} + +{% block breadcrumbs %}{% endblock %} + +{% block content %} +{% if form.errors and not form.non_field_errors and not form.this_is_the_login_form.errors %} +

+{% blocktrans count form.errors.items|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} +

+{% endif %} + +{% if form.non_field_errors or form.this_is_the_login_form.errors %} +{% for error in form.non_field_errors|add:form.this_is_the_login_form.errors %} +

+ {{ error }} +

+{% endfor %} +{% endif %} + +
+
{% csrf_token %} +
+ {% if not form.this_is_the_login_form.errors %}{{ form.email.errors }}{% endif %} + {{ form.email }} +
+
+ {% if not form.this_is_the_login_form.errors %}{{ form.password.errors }}{% endif %} + {{ form.password }} + + +
+
+ +
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/emailusernames/tests.py b/emailusernames/tests.py new file mode 100644 index 0000000..bb11bdc --- /dev/null +++ b/emailusernames/tests.py @@ -0,0 +1,58 @@ +from django.contrib.auth import authenticate +from django.contrib.auth.models import User +from django.db import IntegrityError +from django.test import TestCase +from emailusernames.utils import create_user + + +class CreateUserTests(TestCase): + """ + Tests which create users. + """ + def setUp(self): + self.email = 'user@example.com' + self.password = 'password' + + def test_can_create_user(self): + user = create_user(self.email, self.password) + self.assertEquals(list(User.objects.all()), [user]) + + def test_can_create_user_with_long_email(self): + padding = 'a' * 30 + create_user(padding + self.email, self.password) + + def test_created_user_has_correct_details(self): + user = create_user(self.email, self.password) + self.assertEquals(user.email, self.email) + + +class ExistingUserTests(TestCase): + """ + Tests which require an existing user. + """ + + def setUp(self): + self.email = 'user@example.com' + self.password = 'password' + self.user = create_user(self.email, self.password) + + def test_user_can_authenticate(self): + auth = authenticate(email=self.email, password=self.password) + self.assertEquals(self.user, auth) + + def test_user_can_authenticate_with_case_insensitive_match(self): + auth = authenticate(email=self.email.upper(), password=self.password) + self.assertEquals(self.user, auth) + + def test_user_emails_are_unique(self): + with self.assertRaises(IntegrityError) as ctx: + create_user(self.email, self.password) + self.assertEquals(ctx.exception.message, 'user email is not unique') + + def test_user_emails_are_case_insensitive_unique(self): + with self.assertRaises(IntegrityError) as ctx: + create_user(self.email.upper(), self.password) + self.assertEquals(ctx.exception.message, 'user email is not unique') + + def test_user_unicode(self): + self.assertEquals(unicode(self.user), self.email) diff --git a/emailusernames/utils.py b/emailusernames/utils.py new file mode 100644 index 0000000..67a51ee --- /dev/null +++ b/emailusernames/utils.py @@ -0,0 +1,62 @@ +import base64 +import hashlib +from django.contrib.auth.models import User +from django.db import IntegrityError + + +# We need to convert emails to hashed versions when we store them in the +# username field. We can't just store them directly, or we'd be limited +# to Django's username <= 30 chars limit, which is really too small for +# arbitrary emails. +def _email_to_username(email): + email = email.lower() # Emails should be case-insensitive unique + return base64.urlsafe_b64encode(hashlib.sha256(email).digest())[:30] + + +def get_user(email): + """ + Return the user with given email address. + Note that email address matches are case-insensitive. + """ + return User.objects.get(email__iexact=email) + + +def user_exists(email): + """ + Return True if a user with given email address exists. + Note that email address matches are case-insensitive. + """ + try: + get_user(email) + except User.DoesNotExist: + return False + return True + + +def create_user(email, password=None, is_staff=None, is_active=None): + """ + Create a new user with the given email. + Use this instead of `User.objects.create_user`. + """ + try: + user = User.objects.create_user(None, email, password) + except IntegrityError, err: + if err.message == 'column username is not unique': + raise IntegrityError('user email is not unique') + raise + + if is_active is not None or is_staff is not None: + if is_active is not None: + user.is_active = is_active + if is_staff is not None: + user.is_staff = is_staff + user.save() + return user + + +def create_superuser(email, password): + """ + Create a new superuser with the given email. + Use this instead of `User.objects.create_superuser`. + """ + return User.objects.create_superuser(None, email, password) diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..ada6d5f --- /dev/null +++ b/setup.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os +import sys +from distutils.core import setup +import emailusernames + +version = emailusernames.__version__ + +if sys.argv[-1] == 'publish': + os.system("python setup.py sdist upload") + print "You probably want to also tag the version now:" + print " git tag -a %s -m 'version %s'" % (version, version) + print " git push --tags" + sys.exit() + +setup( + name='django-email-as-username', + version=version, + description='User authentication with email addresses instead of usernames.', + author='Tom Christie', + url='https://github.com/dabapps/django-email-as-username', + packages=['emailusernames', ], + install_requires=[], + license='BSD', +)