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:
- GitHub —
allauth.socialaccount.providers.github - Google —
allauth.socialaccount.providers.google - GitLab —
allauth.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 callssociallogin.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:
Look before you leap — this lists exactly what the periodic task would delete, and deletes nothing:
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:
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)
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:
- Suppresses "account already exists" emails — a common anti-pattern that leaks information about registered users.
- 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:
- Verified email — the user must have a verified primary email address. If not, they are redirected to the email management page.
- 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.