How to replace username with email in Django authentication?
This article will explain step-by-step how to create a custom User model in Django where email is used as a primary user instead of a username for authentication.
Note that doing this will greatly alter the database schema so it is advisable to work on a new project. For a project that has been developed in the past it is necessary to backup the data and recreate the DB.
Objectives
After this article you can learn:
- Explain the difference between AbstractUser and AbstractBaseUser
- Explain why it is necessary to set up a custom User model when starting a new Django project.
- Start a Django project with a custom User model
- Use an email address as a primary user instead of a username for authentication.
- Practice with test-first development when implementing a custom User model.
AbstractUser with AbstractBaseUser
By default, the User model in Django uses a username for authentication. Instead if you wish to use email, you will need to create a custom User model using either the AbstractUser or AbstractBaseUser subclassing.
Options
- AbstractUser: Use this option if you want to use the existing User fields and just want to remove the username field.
- AbstractBaseUser: Use this option if you want to create a completely new User model.
We will try both with these two options. The steps are the same for both options:
- Create a custom User model and Manager
- Update setting.py
- Customize UserCreationForm and UserChangeForm
- Update admin
It must be emphasized that the custom User model is used when starting a new Django project. If not for the new project we need to create another model like UserProfile and link it to Django’s User model with OneToOneField if you want to add a new field to the User model.
Project Setup
Proceed to create a new project with a users app.
1 2 3 4 5 6 7 8 | $ mkdir django-custom-user-model && cd django-custom-user-model $ python3 -m venv env $ source env/bin/activate (env)$ pip install django==3.0.4 (env)$ django-admin.py startproject hello_django . (env)$ python manage.py startapp users |
Migrations have not been conducted. Remember, you need to create a custom User model before you apply for the first migration.
Add the app to the list of INSTALLED_APPS in setting.py :
1 2 3 4 5 6 7 8 9 10 11 | INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'users', ] |
Kiểm TRA
We’ll take a test first by adding the following code in users / tests.py and make sure tests fail.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | from django.test import TestCase from django.contrib.auth import get_user_model class UsersManagersTests(TestCase): def test_create_user(self): User = get_user_model() user = User.objects.create_user(email=' <a class="__cf_email__" href="/cdn-cgi/l/email-protection">[email protected]</a> ', password='foo') self.assertEqual(user.email, ' <a class="__cf_email__" href="/cdn-cgi/l/email-protection">[email protected]</a> ') self.assertTrue(user.is_active) self.assertFalse(user.is_staff) self.assertFalse(user.is_superuser) try: # username is None for the AbstractUser option # username does not exist for the AbstractBaseUser option self.assertIsNone(user.username) except AttributeError: pass with self.assertRaises(TypeError): User.objects.create_user() with self.assertRaises(TypeError): User.objects.create_user(email='') with self.assertRaises(ValueError): User.objects.create_user(email='', password="foo") def test_create_superuser(self): User = get_user_model() admin_user = User.objects.create_superuser(' <a class="__cf_email__" href="/cdn-cgi/l/email-protection">[email protected]</a> ', 'foo') self.assertEqual(admin_user.email, ' <a class="__cf_email__" href="/cdn-cgi/l/email-protection">[email protected]</a> ') self.assertTrue(admin_user.is_active) self.assertTrue(admin_user.is_staff) self.assertTrue(admin_user.is_superuser) try: # username is None for the AbstractUser option # username does not exist for the AbstractBaseUser option self.assertIsNone(admin_user.username) except AttributeError: pass with self.assertRaises(ValueError): User.objects.create_superuser( email=' <a class="__cf_email__" href="/cdn-cgi/l/email-protection">[email protected]</a> ', password='foo', is_superuser=False) |
Model Manager
We need the custom Manager by the BaseUserManager subclassing, using an email as the unique identifier instead of the username.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | class CustomUserManager(BaseUserManager): """ Custom user model manager where email is the unique identifiers for authentication instead of usernames. """ def create_user(self, email, password, **extra_fields): """ Create and save a User with the given email and password. """ if not email: raise ValueError(_('The Email must be set')) email = self.normalize_email(email) user = self.model(email=email, **extra_fields) user.set_password(password) user.save() return user def create_superuser(self, email, password, **extra_fields): """ Create and save a SuperUser with the given email and password. """ extra_fields.setdefault('is_staff', True) extra_fields.setdefault('is_superuser', True) extra_fields.setdefault('is_active', True) if extra_fields.get('is_staff') is not True: raise ValueError(_('Superuser must have is_staff=True.')) if extra_fields.get('is_superuser') is not True: raise ValueError(_('Superuser must have is_superuser=True.')) return self.create_user(email, password, **extra_fields) |
User Model
Select either option using the subclassing AbstractUser or AbstractBaseUser.
AbstractUser
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | Update users/models.py from django.db import models from django.contrib.auth.models import AbstractUser from django.utils.translation import ugettext_lazy as _ from .managers import CustomUserManager class CustomUser(AbstractUser): username = None email = models.EmailField(_('email address'), unique=True) USERNAME_FIELD = 'email' REQUIRED_FIELDS = [] objects = CustomUserManager() def __str__(self): return self.email |
In the above code:
- Create a class called CustomUser subclasses AbstractUser
- Remove username field
- Make the email a require and unique fields
- Set the value USERNAME_FIELD – define the unique identifier for the User model as email.
- Indicates all objects managed by CustomUserManager.
AbstractBaseUser Update users / models.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | from django.contrib.auth.models import AbstractBaseUser from django.contrib.auth.models import PermissionsMixin from django.utils.translation import gettext_lazy as _ from django.utils import timezone from .managers import CustomUserManager class CustomUser(AbstractBaseUser, PermissionsMixin): email = models.EmailField(_('email address'), unique=True) is_staff = models.BooleanField(default=False) is_active = models.BooleanField(default=True) date_joined = models.DateTimeField(default=timezone.now) USERNAME_FIELD = 'email' REQUIRED_FIELDS = [] objects = CustomUserManager() def __str__(self): return self.email |
In the above code:
- Create a class named CustomUser subclasses AbstractBaseUser
- Adds the email, is_staff, is_active, date_joined fields.
- Set the value USERNAME_FIELD – define the unique identifier for the User model as email.
- Indicates all objects managed by CustomUserManager.
Settings
Add the settings.py line below to let Django know that he will be using the new User class.
AUTH_USER_MODEL = 'users.CustomUser'
Next try applying migrations to create a new database using the custom User model. Before we do this we will use the –dry-run flag to determine what will look like after migrations, with this flag the migration file has not yet been generated.
python manage.py makemigrations --dry-run --verbosity 3
Case customUser subclasses AbstractUser
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | Generated by Django 3.1.5 on 2021-01-18 22:41 from django.db import migrations, models import django.utils.timezone class Migration(migrations.Migration): dependencies = [ ('users', '0002_auto_20210118_1326'), ] operations = [ migrations.AlterModelOptions( name='customuser', options={'verbose_name': 'user', 'verbose_name_plural': 'users'}, ), migrations.AddField( model_name='customuser', name='first_name', field=models.CharField(blank=True, max_length=150, verbose_name='first name'), ), migrations.AddField( model_name='customuser', name='last_name', field=models.CharField(blank=True, max_length=150, verbose_name='last name'), ), migrations.AlterField( model_name='customuser', name='date_joined', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined'), ), migrations.AlterField( model_name='customuser', name='is_active', field=models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active'), ), migrations.AlterField( model_name='customuser', name='is_staff', field=models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status'), ), ] |
Case customUser subclasses AbstractBaseUser
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | Generated by Django 3.1.5 on 2021-01-18 22:45 from django.db import migrations, models import django.utils.timezone class Migration(migrations.Migration): initial = True dependencies = [ ('auth', '0012_alter_user_first_name_max_length'), ] operations = [ migrations.CreateModel( name='CustomUser', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), ('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')), ('is_staff', models.BooleanField(default=False)), ('is_active', models.BooleanField(default=True)), ('date_joined', models.DateTimeField(default=django.utils.timezone.now)), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), ], options={ 'abstract': False, }, ), ] |
Confirm that the migtations does not contain a username. Next create and apply migration.
1 2 3 | python manage.py makemigrations python manage.py migrate |
View schema:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | sqlite3 db.sqlite3 sqlite> .tables Case customUser subclasses AbstractBaseUser: CREATE TABLE IF NOT EXISTS "users_customuser" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "password" varchar(128) NOT NULL, "last_login" datetime NULL, "is_superuser" bool NOT NULL, "is_active" bool NOT NULL, "date_joined" datetime NOT NULL, "email" varchar(254) NOT NULL UNIQUE, "is_staff" bool NOT NULL); Bạn có thể reference User model với get_user_model() hoặc settings.AUTH_USER_MODEL. Khi bạn taọ superuser lúc này hệ thống sẽ yêu cầu bạn nhập vào một email thay vì một username. (env)$ python manage.py createsuperuser Email address: <a class="__cf_email__" href="/cdn-cgi/l/email-protection">[email protected]</a> Password: Password (again): Superuser created successfully |
Rerun tests
1 2 3 4 5 6 7 8 | System check identified no issues (0 silenced). .. ---------------------------------------------------------------------- Ran 2 tests in 0.237s OK Destroying test database for alias 'default'... |
Forms
Subclass UserCreationForm and UserChangeForm with new CustomUser. Create a new file in users with the name forms.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | from .models import CustomUser class CustomUserCreationForm(UserCreationForm): class Meta(UserCreationForm): model = CustomUser fields = ('email',) class CustomUserChangeForm(UserChangeForm): class Meta: model = CustomUser fields = ('email',) |
Admin
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | from django.contrib import admin from django.contrib.auth.admin import UserAdmin from .forms import CustomUserCreationForm, CustomUserChangeForm from .models import CustomUser class CustomUserAdmin(UserAdmin): add_form = CustomUserCreationForm form = CustomUserChangeForm model = CustomUser list_display = ('email', 'is_staff', 'is_active',) list_filter = ('email', 'is_staff', 'is_active',) fieldsets = ( (None, {'fields': ('email', 'password')}), ('Permissions', {'fields': ('is_staff', 'is_active')}), ) add_fieldsets = ( (None, { 'classes': ('wide',), 'fields': ('email', 'password1', 'password2', 'is_staff', 'is_active')} ), ) search_fields = ('email',) ordering = ('email',) admin.site.register(CustomUser, CustomUserAdmin) |
Run the server, login to the admin site. You can add users with email as usual.
Conclusion
In this article we have seen how to custom User model where email is used as primary user identifier instead of username for authentication. Another recommended way to use it is to create a one-to-one model with Django’s User model as Profile model and add new fields under Profile.