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 %} + +