Skip to content

Authentication

Overview

SpeedPy uses django-allauth for authentication. It's configured for email-based login (no usernames) with mandatory email verification.

Configuration

Key settings in project/settings.py:

ACCOUNT_LOGIN_METHODS = {"email"}
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*"]
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
ACCOUNT_ADAPTER = "usermodel.adapters.CustomAccountAdapter"
LOGIN_REDIRECT_URL = reverse_lazy("dashboard")

Authentication backends:

AUTHENTICATION_BACKENDS = [
    "django.contrib.auth.backends.ModelBackend",       # Django admin
    "allauth.account.auth_backends.AuthenticationBackend",  # allauth
]

Social Authentication

SpeedPy comes with three social auth providers pre-installed:

  • GitHuballauth.socialaccount.providers.github
  • Googleallauth.socialaccount.providers.google
  • GitLaballauth.socialaccount.providers.gitlab

To enable a provider, add its credentials in the Django admin under Social Applications, or configure them in settings via SOCIALACCOUNT_PROVIDERS:

SOCIALACCOUNT_PROVIDERS = {}
if GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET:
    SOCIALACCOUNT_PROVIDERS["google"] = {
        "APPS": [{
            "client_id": GOOGLE_OAUTH_CLIENT_ID,
            "secret": GOOGLE_OAUTH_CLIENT_SECRET,
            "key": "",
        }],
        "SCOPE": ["profile", "email"],
        "AUTH_PARAMS": {"access_type": "online"},
    }

Configure a provider only when you have both values. A provider registered with an empty client id still renders a sign-in button, and clicking it fails at the provider rather than in your app — a broken button is worse than no button. allauth's list_providers skips any provider with no configured app, so the three pre-installed providers stay invisible until you configure one.

Use access_type: "online" unless you actually call the provider's APIs on the user's behalf. Asking for offline access hands you a refresh token you then have to store and protect for no reason.

To add more providers, install the relevant allauth provider package and add it to INSTALLED_APPS.

Decide what happens when the email already exists

Someone signs up with a password. Months later they click "Continue with Google", and Google returns the same address. What should happen?

allauth's defaults are safe — SOCIALACCOUNT_EMAIL_AUTHENTICATION and SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT are both False, so it will not link the accounts automatically. But its fallback is to show a signup form for an address that already exists, which confuses the user and reveals that the account exists.

The better behaviour is to refuse and say what to do instead — sign in the original way, then connect the provider from account settings. Implement it in your own social adapter:

class MySocialAccountAdapter(CustomSocialAccountAdapter):
    def pre_social_login(self, request, sociallogin):
        super().pre_social_login(request, sociallogin)
        if sociallogin.is_existing or request.user.is_authenticated:
            return                      # returning user, or connecting on purpose
        email = (sociallogin.user.email or "").strip().lower()
        if email and EmailAddress.objects.filter(email__iexact=email).exists():
            messages.error(request, "An account already exists for that email...")
            raise ImmediateHttpResponse(redirect(reverse("account_login")))

Three details that are easy to get wrong:

  • Return early on sociallogin.is_existing, or you lock out every returning social user. It is safe to rely on: allauth calls sociallogin.lookup() immediately before this hook.
  • Return early when the user is already authenticated, or you break the connect-from-settings flow you just told people to use.
  • Match unverified addresses too. If you only check verified ones, the rule is bypassed by signing up with somebody else's address and never confirming it.

Do not link automatically. Whoever controls the provider account would then control the local account, and the owner never sees it happen.

Social signups and your terms checkbox

If you require terms or privacy acceptance at signup (see Signup with TOS & Privacy Policy), note that SOCIALACCOUNT_AUTO_SIGNUP defaults to True — a social signup creates the account immediately and never shows those checkboxes. The point of an explicit checkbox is provable consent, so a signup route that skips it defeats it.

Set SOCIALACCOUNT_AUTO_SIGNUP = False and supply a social signup form carrying the same fields:

SOCIALACCOUNT_AUTO_SIGNUP = False
SOCIALACCOUNT_FORMS = {"signup": "myapp.forms.MySocialSignupForm"}

Subclass allauth.socialaccount.forms.SignupForm and add your tos/dpa fields. Show the provider's email read-only: making it editable creates an unverified address on a route that has no verification step. The cost is one extra click.

Blocking email domains at signup

usermodel/forms.py::clean_email refuses throwaway-mail providers from a bundled list of ~8,000 domains, plus any domains you add yourself, before it does the MX lookup. On by default.

See Blocked email domains for the two lists, how to refresh the bundled one, and why the refusal message deliberately says nothing about why.

Purging unconfirmed signups

With mandatory email verification, a signup that never confirms leaves a row nobody can ever use. The person cannot sign in — and if their address is on the suppression list (a past bounce or spam complaint) they cannot even be sent another confirmation, because the mail is dropped before it reaches the provider. The account exists and does nothing.

Off by default, because a starter template that deletes user accounts on a timer without being asked would be a nasty surprise:

# 0 = off. 7 is the recommended value.
SPEEDPY_UNCONFIRMED_ACCOUNT_PURGE_DAYS = 7

Look before you leap — this lists exactly what the periodic task would delete, and deletes nothing:

python manage.py purge_unconfirmed_accounts --dry-run

A daily Celery task (purge_unconfirmed_accounts, beat at 04:30) does the work once the setting is on.

What it will and will not touch

A row must be all of these before it can be deleted. Each exclusion is somebody's real account:

Rule Why
active, not staff, not a superuser never touch an operator's account
never logged in (last_login is null) somebody got in with it once; it is established, whatever its addresses look like now
at least one EmailAddress row, none verified the "at least one" protects a user created by hand in the admin — such a row often has no EmailAddress at all, and would match a bare "no verified address" test
older than the window see below

The window must be longer than ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS (allauth's default is 3), or the purge races the last valid click on a confirmation link. 7 days against a 3-day link is a safe pairing.

Cleaning up what hangs off the account

A User delete cascades what points at the user, and nothing else. In a teams project that leaves the team behind for good: Team has no foreign key to a user — membership is the only link, and that cascades — so the team, its projects and its uploaded files would survive with nobody able to reach them.

The hook is registered by default:

SPEEDPY_UNCONFIRMED_ACCOUNT_PURGE_HOOKS = [
    "mainapp.models.teams.delete_sole_member_teams",
]

It deletes only teams where the purged user was the last member — a shared team belongs to whoever is left — and it goes through the team deletion service, so your storage cleanup runs and a team that is still being charged raises instead. That keeps the account too, deliberately: a paid team is not something to remove on a timer.

Your own hooks follow the same contract: idempotent, and raise on failure so the transaction rolls back and the account is retried rather than half removed.

Changing the rules

Subclass; do not edit the package. speedpycom is meant to stay pristine so that updates keep merging cleanly.

# myapp/purge.py
from speedpycom.services.account_purge import UnconfirmedAccountPurge

class OurPurge(UnconfirmedAccountPurge):
    def queryset(self):
        # never touch anybody who was ever invited to a team
        return super().queryset().filter(teammembership__isnull=True)
SPEEDPY_UNCONFIRMED_ACCOUNT_PURGE_CLASS = "myapp.purge.OurPurge"

Custom Forms

All auth forms are customized with Crispy Forms + Tailwind styling. They're registered in ACCOUNT_FORMS:

ACCOUNT_FORMS = {
    "signup": "usermodel.forms.UsermodelSignupForm",
    "login": "usermodel.forms.UsermodelLoginForm",
    "reset_password": "usermodel.forms.UsermodelResetPasswordForm",
    "reset_password_from_key": "usermodel.forms.UsermodelResetPasswordKeyForm",
    "change_password": "usermodel.forms.UsermodelChangePasswordForm",
    "add_email": "usermodel.forms.UsermodelAddEmailForm",
}

Each form uses FormHelper with a Layout for consistent Tailwind styling.

Signup with TOS & Privacy Policy

The signup form includes optional Terms of Service and Privacy Policy checkboxes, controlled by settings:

REQUIRE_TOS_ACCEPTANCE = True
REQUIRE_DPA_ACCEPTANCE = True
TOS_LINK = env("TOS_LINK", default="/")
DPA_LINK = env("DPA_LINK", default="/")

When enabled, users must check these boxes to complete registration.

Custom Account Adapter

The CustomAccountAdapter in usermodel/adapters.py adds two features:

  1. Suppresses "account already exists" emails — a common anti-pattern that leaks information about registered users.
  2. OTP integration — if a user has two-factor authentication enabled, the adapter redirects to the OTP verification page instead of completing login immediately.

Known bug: the OTP check does not cover social logins

The adapter also defines pre_social_login, which looks like it applies the same OTP check to GitHub, Google and GitLab sign-ins. It never runs. allauth calls that hook on the socialaccount adapter (allauth/socialaccount/internal/flows/login.py calls get_adapter().pre_social_login(...), resolved through SOCIALACCOUNT_ADAPTER), not on the account adapter.

So with SPEEDPY_MFA_BACKEND=django_otp, a user who has enabled two-factor authentication can sign in through a social provider and skip it. The code reads as though this is handled, which is what makes it worth knowing about.

Until it is fixed upstream, move the method onto CustomSocialAccountAdapter, or subclass that adapter in your own app and point SOCIALACCOUNT_ADAPTER at yours. See Two-factor authentication for whether you want to enforce it at all.

Personal Access Token (PAT) Creation

PAT creation at /accounts/tokens/create/ enforces two security gates:

  1. Verified email — the user must have a verified primary email address. If not, they are redirected to the email management page.
  2. Recent reauthentication — the user must have authenticated recently (within allauth's ACCOUNT_REAUTHENTICATION_TIMEOUT, default 300 seconds). If not, they are redirected to /accounts/reauthenticate/ and returned after completing the challenge. When MFA is enabled, the reauthentication flow requires the stronger factor (TOTP).

Both gates can be disabled via settings — see Security for details.

User Profile

A profile edit view is available at /accounts/profile/ using UserProfileForm, which allows editing first_name and last_name.