Skip to content
Browse files

First commit

  • Loading branch information...
0 parents commit 735249cecdd6652c9480862e4ca602ede1dfa97a @tomchristie tomchristie committed Feb 2, 2012
3 .gitignore
@@ -0,0 +1,3 @@
+*.pyc
+MANIFEST
+dist/
117 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
1 emailusernames/__init__.py
@@ -0,0 +1 @@
+__version__ = '0.1.0'
36 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
25 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
102 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']
0 emailusernames/management/__init__.py
No changes.
0 emailusernames/management/commands/__init__.py
No changes.
102 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")
28 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'
57 emailusernames/templates/email_usernames/login.html
@@ -0,0 +1,57 @@
+{% extends "admin/base_site.html" %}
+{% load i18n %}
+
+{% block extrastyle %}{% load adminmedia %}{{ block.super }}
+<link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/login.css" />
+<style type="text/css">
+ .login .form-row #id_email {
+ width: 14em;
+ }
+</style>
+{% 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 %}
+<p class="errornote">
+{% blocktrans count form.errors.items|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %}
+</p>
+{% 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 %}
+<p class="errornote">
+ {{ error }}
+</p>
+{% endfor %}
+{% endif %}
+
+<div id="content-main">
+<form action="{{ app_path }}" method="post" id="login-form">{% csrf_token %}
+ <div class="form-row">
+ {% if not form.this_is_the_login_form.errors %}{{ form.email.errors }}{% endif %}
+ <label for="id_email" class="required">{% trans 'Email:' %}</label> {{ form.email }}
+ </div>
+ <div class="form-row">
+ {% if not form.this_is_the_login_form.errors %}{{ form.password.errors }}{% endif %}
+ <label for="id_password" class="required">{% trans 'Password:' %}</label> {{ form.password }}
+ <input type="hidden" name="this_is_the_login_form" value="1" />
+ <input type="hidden" name="next" value="{{ next }}" />
+ </div>
+ <div class="submit-row">
+ <label>&nbsp;</label><input type="submit" value="{% trans 'Log in' %}" />
+ </div>
+</form>
+
+<script type="text/javascript">
+document.getElementById('id_username').focus()
+</script>
+</div>
+{% endblock %}
58 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)
62 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)
27 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',
+)

0 comments on commit 735249c

Please sign in to comment.
Something went wrong with that request. Please try again.