okay fine

This commit is contained in:
pacnpal
2024-11-03 17:47:26 +00:00
parent 387c4740e7
commit 27f3326e22
10020 changed files with 1935769 additions and 2364 deletions

View File

@@ -0,0 +1,20 @@
r"""
_ ___ __ __ .___________. __ __
/\| |/\ / \ | | | | | || | | |
\ ` ' / / ^ \ | | | | `---| |----`| |__| |
|_ _| / /_\ \ | | | | | | | __ |
/ , . \ / _____ \ | `--' | | | | | | |
\/|_|\//__/ \__\ \______/ |__| |__| |__|
"""
VERSION = (65, 1, 0, "final", 0)
__title__ = "django-allauth"
__version_info__ = VERSION
__version__ = ".".join(map(str, VERSION[:3])) + (
"-{}{}".format(VERSION[3], VERSION[4] or "") if VERSION[3] != "final" else ""
)
__author__ = "Raymond Penners"
__license__ = "MIT"
__copyright__ = "Copyright 2010-2024 Raymond Penners and contributors"

View File

@@ -0,0 +1,826 @@
import html
import json
import string
import warnings
from urllib.parse import urlparse
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import (
authenticate,
get_backends,
get_user_model,
login as django_login,
logout as django_logout,
)
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.password_validation import (
MinimumLengthValidator,
validate_password,
)
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import FieldDoesNotExist
from django.core.mail import EmailMessage, EmailMultiAlternatives
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import resolve_url
from django.template import TemplateDoesNotExist
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.encoding import force_str
from django.utils.translation import gettext_lazy as _
from allauth import app_settings as allauth_app_settings
from allauth.account import app_settings, signals
from allauth.core import context, ratelimit
from allauth.core.internal.adapter import BaseAdapter
from allauth.core.internal.httpkit import headed_redirect_response
from allauth.utils import generate_unique_username, import_attribute
class DefaultAccountAdapter(BaseAdapter):
"""The adapter class allows you to override various functionality of the
``allauth.account`` app. To do so, point ``settings.ACCOUNT_ADAPTER`` to
your own class that derives from ``DefaultAccountAdapter`` and override the
behavior by altering the implementation of the methods according to your own
needs.
"""
error_messages = {
"account_inactive": _("This account is currently inactive."),
"cannot_remove_primary_email": _(
"You cannot remove your primary email address."
),
"duplicate_email": _(
"This email address is already associated with this account."
),
"email_password_mismatch": _(
"The email address and/or password you specified are not correct."
),
"email_taken": _("A user is already registered with this email address."),
"enter_current_password": _("Please type your current password."),
"incorrect_code": _("Incorrect code."),
"incorrect_password": _("Incorrect password."),
"invalid_or_expired_key": _("Invalid or expired key."),
"invalid_password_reset": _("The password reset token was invalid."),
"max_email_addresses": _("You cannot add more than %d email addresses."),
"too_many_login_attempts": _(
"Too many failed login attempts. Try again later."
),
"unknown_email": _("The email address is not assigned to any user account"),
"unverified_primary_email": _("Your primary email address must be verified."),
"username_blacklisted": _(
"Username can not be used. Please use other username."
),
"username_password_mismatch": _(
"The username and/or password you specified are not correct."
),
"username_taken": AbstractUser._meta.get_field("username").error_messages[
"unique"
],
}
def stash_verified_email(self, request, email):
request.session["account_verified_email"] = email
def unstash_verified_email(self, request):
ret = request.session.get("account_verified_email")
request.session["account_verified_email"] = None
return ret
def is_email_verified(self, request, email):
"""
Checks whether or not the email address is already verified
beyond allauth scope, for example, by having accepted an
invitation before signing up.
"""
ret = False
verified_email = request.session.get("account_verified_email")
if verified_email:
ret = verified_email.lower() == email.lower()
return ret
def can_delete_email(self, email_address) -> bool:
"""
Returns whether or not the given email address can be deleted.
"""
from allauth.account.models import EmailAddress
has_other = (
EmailAddress.objects.filter(user_id=email_address.user_id)
.exclude(pk=email_address.pk)
.exists()
)
login_by_email = (
app_settings.AUTHENTICATION_METHOD
== app_settings.AuthenticationMethod.EMAIL
)
if email_address.primary:
if has_other:
# Don't allow, let the user mark one of the others as primary
# first.
return False
elif login_by_email:
# Last email & login is by email, prevent dangling account.
return False
return True
elif has_other:
# Account won't be dangling.
return True
elif login_by_email:
# This is the last email.
return False
else:
return True
def format_email_subject(self, subject) -> str:
"""
Formats the given email subject.
"""
prefix = app_settings.EMAIL_SUBJECT_PREFIX
if prefix is None:
site = get_current_site(context.request)
prefix = "[{name}] ".format(name=site.name)
return prefix + force_str(subject)
def get_from_email(self):
"""
This is a hook that can be overridden to programmatically
set the 'from' email address for sending emails
"""
return settings.DEFAULT_FROM_EMAIL
def render_mail(self, template_prefix, email, context, headers=None):
"""
Renders an email to `email`. `template_prefix` identifies the
email that is to be sent, e.g. "account/email/email_confirmation"
"""
to = [email] if isinstance(email, str) else email
subject = render_to_string("{0}_subject.txt".format(template_prefix), context)
# remove superfluous line breaks
subject = " ".join(subject.splitlines()).strip()
subject = self.format_email_subject(subject)
from_email = self.get_from_email()
bodies = {}
html_ext = app_settings.TEMPLATE_EXTENSION
for ext in [html_ext, "txt"]:
try:
template_name = "{0}_message.{1}".format(template_prefix, ext)
bodies[ext] = render_to_string(
template_name,
context,
globals()["context"].request,
).strip()
except TemplateDoesNotExist:
if ext == "txt" and not bodies:
# We need at least one body
raise
if "txt" in bodies:
msg = EmailMultiAlternatives(
subject, bodies["txt"], from_email, to, headers=headers
)
if html_ext in bodies:
msg.attach_alternative(bodies[html_ext], "text/html")
else:
msg = EmailMessage(
subject, bodies[html_ext], from_email, to, headers=headers
)
msg.content_subtype = "html" # Main content is now text/html
return msg
def send_mail(self, template_prefix, email, context):
ctx = {
"email": email,
"current_site": get_current_site(globals()["context"].request),
}
ctx.update(context)
msg = self.render_mail(template_prefix, email, ctx)
msg.send()
def get_signup_redirect_url(self, request):
return resolve_url(app_settings.SIGNUP_REDIRECT_URL)
def get_login_redirect_url(self, request):
"""
Returns the default URL to redirect to after logging in. Note
that URLs passed explicitly (e.g. by passing along a `next`
GET parameter) take precedence over the value returned here.
"""
assert request.user.is_authenticated
url = getattr(settings, "LOGIN_REDIRECT_URLNAME", None)
if url:
warnings.warn(
"LOGIN_REDIRECT_URLNAME is deprecated, simply"
" use LOGIN_REDIRECT_URL with a URL name",
DeprecationWarning,
)
else:
url = settings.LOGIN_REDIRECT_URL
return resolve_url(url)
def get_logout_redirect_url(self, request):
"""
Returns the URL to redirect to after the user logs out. Note that
this method is also invoked if you attempt to log out while no users
is logged in. Therefore, request.user is not guaranteed to be an
authenticated user.
"""
return resolve_url(app_settings.LOGOUT_REDIRECT_URL)
def get_email_verification_redirect_url(self, email_address):
"""
The URL to return to after email verification.
"""
get_url = getattr(self, "get_email_confirmation_redirect_url", None)
if get_url:
# Deprecated.
return get_url(self.request)
if self.request.user.is_authenticated:
if app_settings.EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL:
return app_settings.EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL
else:
return self.get_login_redirect_url(self.request)
else:
return app_settings.EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL
def get_password_change_redirect_url(self, request):
"""
The URL to redirect to after a successful password change/set.
NOTE: Not called during the password reset flow.
"""
return reverse("account_change_password")
def is_open_for_signup(self, request):
"""
Checks whether or not the site is open for signups.
Next to simply returning True/False you can also intervene the
regular flow by raising an ImmediateHttpResponse
"""
return True
def new_user(self, request):
"""
Instantiates a new User instance.
"""
user = get_user_model()()
return user
def populate_username(self, request, user):
"""
Fills in a valid username, if required and missing. If the
username is already present it is assumed to be valid
(unique).
"""
from .utils import user_email, user_field, user_username
first_name = user_field(user, "first_name")
last_name = user_field(user, "last_name")
email = user_email(user)
username = user_username(user)
if app_settings.USER_MODEL_USERNAME_FIELD:
user_username(
user,
username
or self.generate_unique_username(
[first_name, last_name, email, username, "user"]
),
)
def generate_unique_username(self, txts, regex=None):
return generate_unique_username(txts, regex)
def save_user(self, request, user, form, commit=True):
"""
Saves a new `User` instance using information provided in the
signup form.
"""
from .utils import user_email, user_field, user_username
data = form.cleaned_data
first_name = data.get("first_name")
last_name = data.get("last_name")
email = data.get("email")
username = data.get("username")
user_email(user, email)
user_username(user, username)
if first_name:
user_field(user, "first_name", first_name)
if last_name:
user_field(user, "last_name", last_name)
if "password1" in data:
user.set_password(data["password1"])
elif "password" in data:
user.set_password(data["password"])
else:
user.set_unusable_password()
self.populate_username(request, user)
if commit:
# Ability not to commit makes it easier to derive from
# this adapter by adding
user.save()
return user
def clean_username(self, username, shallow=False):
"""
Validates the username. You can hook into this if you want to
(dynamically) restrict what usernames can be chosen.
"""
for validator in app_settings.USERNAME_VALIDATORS:
validator(username)
# TODO: Add regexp support to USERNAME_BLACKLIST
username_blacklist_lower = [
ub.lower() for ub in app_settings.USERNAME_BLACKLIST
]
if username.lower() in username_blacklist_lower:
raise self.validation_error("username_blacklisted")
# Skipping database lookups when shallow is True, needed for unique
# username generation.
if not shallow:
from .utils import filter_users_by_username
if filter_users_by_username(username).exists():
raise self.validation_error("username_taken")
return username
def clean_email(self, email):
"""
Validates an email value. You can hook into this if you want to
(dynamically) restrict what email addresses can be chosen.
"""
return email
def clean_password(self, password, user=None):
"""
Validates a password. You can hook into this if you want to
restric the allowed password choices.
"""
min_length = app_settings.PASSWORD_MIN_LENGTH
if min_length:
MinimumLengthValidator(min_length).validate(password)
validate_password(password, user)
return password
def validate_unique_email(self, email):
return email
def add_message(
self,
request,
level,
message_template=None,
message_context=None,
extra_tags="",
message=None,
):
"""
Wrapper of `django.contrib.messages.add_message`, that reads
the message text from a template.
"""
if getattr(getattr(request, "allauth", None), "headless", None):
return
if "django.contrib.messages" in settings.INSTALLED_APPS:
if message:
messages.add_message(request, level, message, extra_tags=extra_tags)
return
try:
if message_context is None:
message_context = {}
escaped_message = render_to_string(
message_template,
message_context,
context.request,
).strip()
if escaped_message:
message = html.unescape(escaped_message)
messages.add_message(request, level, message, extra_tags=extra_tags)
except TemplateDoesNotExist:
pass
def ajax_response(self, request, response, redirect_to=None, form=None, data=None):
resp = {}
status = response.status_code
if redirect_to:
status = 200
resp["location"] = redirect_to
if form:
if request.method == "POST":
if form.is_valid():
status = 200
else:
status = 400
else:
status = 200
resp["form"] = self.ajax_response_form(form)
if hasattr(response, "render"):
response.render()
resp["html"] = response.content.decode("utf8")
if data is not None:
resp["data"] = data
return HttpResponse(
json.dumps(resp), status=status, content_type="application/json"
)
def ajax_response_form(self, form):
form_spec = {
"fields": {},
"field_order": [],
"errors": form.non_field_errors(),
}
for field in form:
field_spec = {
"label": force_str(field.label),
"value": field.value(),
"help_text": force_str(field.help_text),
"errors": [force_str(e) for e in field.errors],
"widget": {
"attrs": {
k: force_str(v) for k, v in field.field.widget.attrs.items()
}
},
}
form_spec["fields"][field.html_name] = field_spec
form_spec["field_order"].append(field.html_name)
return form_spec
def pre_login(
self,
request,
user,
*,
email_verification,
signal_kwargs,
email,
signup,
redirect_url
):
if not user.is_active:
return self.respond_user_inactive(request, user)
def post_login(
self,
request,
user,
*,
email_verification,
signal_kwargs,
email,
signup,
redirect_url
):
from .utils import get_login_redirect_url
response = HttpResponseRedirect(
get_login_redirect_url(request, redirect_url, signup=signup)
)
if signal_kwargs is None:
signal_kwargs = {}
signals.user_logged_in.send(
sender=user.__class__,
request=request,
response=response,
user=user,
**signal_kwargs,
)
self.add_message(
request,
messages.SUCCESS,
"account/messages/logged_in.txt",
{"user": user},
)
return response
def login(self, request, user):
# HACK: This is not nice. The proper Django way is to use an
# authentication backend
if not hasattr(user, "backend"):
from .auth_backends import AuthenticationBackend
backends = get_backends()
backend = None
for b in backends:
if isinstance(b, AuthenticationBackend):
# prefer our own backend
backend = b
break
elif not backend and hasattr(b, "get_user"):
# Pick the first valid one
backend = b
backend_path = ".".join([backend.__module__, backend.__class__.__name__])
user.backend = backend_path
django_login(request, user)
def logout(self, request):
django_logout(request)
def confirm_email(self, request, email_address):
"""
Marks the email address as confirmed on the db
"""
from allauth.account.internal.flows import email_verification
return email_verification.verify_email(request, email_address)
def set_password(self, user, password) -> None:
"""
Sets the password for the user.
"""
user.set_password(password)
user.save()
def get_user_search_fields(self):
ret = []
User = get_user_model()
candidates = [
app_settings.USER_MODEL_USERNAME_FIELD,
"first_name",
"last_name",
"email",
]
for candidate in candidates:
try:
User._meta.get_field(candidate)
ret.append(candidate)
except FieldDoesNotExist:
pass
return ret
def is_safe_url(self, url):
from django.utils.http import url_has_allowed_host_and_scheme
# get_host already validates the given host, so no need to check it again
allowed_hosts = {context.request.get_host()} | set(settings.ALLOWED_HOSTS)
if "*" in allowed_hosts:
parsed_host = urlparse(url).netloc
allowed_host = {parsed_host} if parsed_host else None
return url_has_allowed_host_and_scheme(url, allowed_hosts=allowed_host)
return url_has_allowed_host_and_scheme(url, allowed_hosts=allowed_hosts)
def send_password_reset_mail(self, user, email, context):
"""
Method intended to be overridden in case you need to customize the logic
used to determine whether a user is permitted to request a password reset.
For example, if you are enforcing something like "social only" authentication
in your app, you may want to intervene here by checking `user.has_usable_password`
"""
return self.send_mail("account/email/password_reset_key", email, context)
def get_reset_password_from_key_url(self, key):
"""
Method intended to be overridden in case the password reset email
needs to be adjusted.
"""
from allauth.account.internal import flows
return flows.password_reset.get_reset_password_from_key_url(self.request, key)
def get_email_confirmation_url(self, request, emailconfirmation):
"""Constructs the email confirmation (activation) url.
Note that if you have architected your system such that email
confirmations are sent outside of the request context `request`
can be `None` here.
"""
from allauth.account.internal import flows
return flows.email_verification.get_email_verification_url(
request, emailconfirmation
)
def should_send_confirmation_mail(self, request, email_address, signup) -> bool:
return True
def send_account_already_exists_mail(self, email):
from allauth.account.internal import flows
signup_url = flows.signup.get_signup_url(context.request)
password_reset_url = flows.password_reset.get_reset_password_url(
context.request
)
ctx = {
"request": context.request,
"signup_url": signup_url,
"password_reset_url": password_reset_url,
}
self.send_mail("account/email/account_already_exists", email, ctx)
def send_confirmation_mail(self, request, emailconfirmation, signup):
ctx = {
"user": emailconfirmation.email_address.user,
}
if app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED:
ctx.update({"code": emailconfirmation.key})
else:
ctx.update(
{
"key": emailconfirmation.key,
"activate_url": self.get_email_confirmation_url(
request, emailconfirmation
),
}
)
if signup:
email_template = "account/email/email_confirmation_signup"
else:
email_template = "account/email/email_confirmation"
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
def respond_user_inactive(self, request, user):
return headed_redirect_response("account_inactive")
def respond_email_verification_sent(self, request, user):
return headed_redirect_response("account_email_verification_sent")
def _get_login_attempts_cache_key(self, request, **credentials):
site = get_current_site(request)
login = credentials.get("email", credentials.get("username", "")).lower()
return "{site}:{login}".format(site=site.domain, login=login)
def _delete_login_attempts_cached_email(self, request, **credentials):
cache_key = self._get_login_attempts_cache_key(request, **credentials)
ratelimit.clear(request, action="login_failed", key=cache_key)
def pre_authenticate(self, request, **credentials):
cache_key = self._get_login_attempts_cache_key(request, **credentials)
if not ratelimit.consume(
request,
action="login_failed",
key=cache_key,
):
raise self.validation_error("too_many_login_attempts")
def authenticate(self, request, **credentials):
"""Only authenticates, does not actually login. See `login`"""
from allauth.account.auth_backends import AuthenticationBackend
self.pre_authenticate(request, **credentials)
AuthenticationBackend.unstash_authenticated_user()
user = authenticate(request, **credentials)
alt_user = AuthenticationBackend.unstash_authenticated_user()
user = user or alt_user
if user:
self._delete_login_attempts_cached_email(request, **credentials)
else:
self.authentication_failed(request, **credentials)
return user
def authentication_failed(self, request, **credentials):
pass
def reauthenticate(self, user, password):
from allauth.account.models import EmailAddress
from allauth.account.utils import user_username
credentials = {"password": password}
username = user_username(user)
if username:
credentials["username"] = username
email = EmailAddress.objects.get_primary_email(user)
if email:
credentials["email"] = email
reauth_user = self.authenticate(context.request, **credentials)
return reauth_user is not None and reauth_user.pk == user.pk
def is_ajax(self, request):
return any(
[
request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest",
request.content_type == "application/json",
request.META.get("HTTP_ACCEPT") == "application/json",
]
)
def get_client_ip(self, request):
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
ip = x_forwarded_for.split(",")[0]
else:
ip = request.META.get("REMOTE_ADDR")
return ip
def get_http_user_agent(self, request):
return request.META.get("HTTP_USER_AGENT", "Unspecified")
def generate_emailconfirmation_key(self, email):
key = get_random_string(64).lower()
return key
def get_login_stages(self):
ret = []
ret.append("allauth.account.stages.LoginByCodeStage")
ret.append("allauth.account.stages.EmailVerificationStage")
if allauth_app_settings.MFA_ENABLED:
from allauth.mfa import app_settings as mfa_settings
ret.append("allauth.mfa.stages.AuthenticateStage")
if mfa_settings.PASSKEY_SIGNUP_ENABLED:
ret.append("allauth.mfa.webauthn.stages.PasskeySignupStage")
return ret
def get_reauthentication_methods(self, user):
"""The order of the methods returned matters. The first method is the
default when using the `@reauthentication_required` decorator.
"""
from allauth.account.internal.flows.reauthentication import (
get_reauthentication_flows,
)
flow_by_id = {f["id"]: f for f in get_reauthentication_flows(user)}
ret = []
if "reauthenticate" in flow_by_id:
entry = {
"id": "reauthenticate",
"description": _("Use your password"),
"url": reverse("account_reauthenticate"),
}
ret.append(entry)
if "mfa_reauthenticate" in flow_by_id:
types = flow_by_id["mfa_reauthenticate"]["types"]
if "recovery_codes" in types or "totp" in types:
entry = {
"id": "mfa_reauthenticate",
"description": _("Use authenticator app or code"),
"url": reverse("mfa_reauthenticate"),
}
ret.append(entry)
if "webauthn" in types:
entry = {
"id": "mfa_reauthenticate:webauthn",
"description": _("Use a security key"),
"url": reverse("mfa_reauthenticate_webauthn"),
}
ret.append(entry)
return ret
def send_notification_mail(self, template_prefix, user, context=None, email=None):
from allauth.account.models import EmailAddress
if not app_settings.EMAIL_NOTIFICATIONS:
return
if not email:
email = EmailAddress.objects.get_primary_email(user)
if not email:
return
ctx = {
"timestamp": timezone.now(),
"ip": self.get_client_ip(self.request),
"user_agent": self.get_http_user_agent(self.request),
}
if context:
ctx.update(context)
self.send_mail(template_prefix, email, ctx)
def generate_login_code(self) -> str:
"""
Generates a new login code.
"""
return self._generate_code()
def generate_email_verification_code(self) -> str:
"""
Generates a new email verification code.
"""
return self._generate_code()
def _generate_code(self):
forbidden_chars = "0OI18B2ZAEU"
allowed_chars = string.ascii_uppercase + string.digits
for ch in forbidden_chars:
allowed_chars = allowed_chars.replace(ch, "")
return get_random_string(length=6, allowed_chars=allowed_chars)
def is_login_by_code_required(self, login) -> bool:
"""
Returns whether or not login-by-code is required for the given
login.
"""
from allauth.account import authentication
method = None
records = authentication.get_authentication_records(self.request)
if records:
method = records[-1]["method"]
if method == "code":
return False
value = app_settings.LOGIN_BY_CODE_REQUIRED
if isinstance(value, bool):
return value
if not value:
return False
return method is None or method in value
def get_adapter(request=None):
return import_attribute(app_settings.ADAPTER)(request)

View File

@@ -0,0 +1,34 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from . import app_settings
from .adapter import get_adapter
from .models import EmailAddress, EmailConfirmation
class EmailAddressAdmin(admin.ModelAdmin):
list_display = ("email", "user", "primary", "verified")
list_filter = ("primary", "verified")
search_fields = []
raw_id_fields = ("user",)
actions = ["make_verified"]
def get_search_fields(self, request):
base_fields = get_adapter().get_user_search_fields()
return ["email"] + list(map(lambda a: "user__" + a, base_fields))
def make_verified(self, request, queryset):
queryset.update(verified=True)
make_verified.short_description = _("Mark selected email addresses as verified") # type: ignore[attr-defined]
class EmailConfirmationAdmin(admin.ModelAdmin):
list_display = ("email_address", "created", "sent", "key")
list_filter = ("sent",)
raw_id_fields = ("email_address",)
if not app_settings.EMAIL_CONFIRMATION_HMAC:
admin.site.register(EmailConfirmation, EmailConfirmationAdmin)
admin.site.register(EmailAddress, EmailAddressAdmin)

View File

@@ -0,0 +1,478 @@
import warnings
from enum import Enum
from typing import Set, Union
from django.core.exceptions import ImproperlyConfigured
class AppSettings:
class AuthenticationMethod(str, Enum):
USERNAME = "username"
EMAIL = "email"
USERNAME_EMAIL = "username_email"
class EmailVerificationMethod(str, Enum):
# After signing up, keep the user account inactive until the email
# address is verified
MANDATORY = "mandatory"
# Allow login with unverified email (email verification is
# still sent)
OPTIONAL = "optional"
# Don't send email verification mails during signup
NONE = "none"
def __init__(self, prefix):
from django.conf import settings
self.prefix = prefix
# If login is by email, email must be required
assert (
not self.AUTHENTICATION_METHOD == self.AuthenticationMethod.EMAIL
) or self.EMAIL_REQUIRED
# If login includes email, login must be unique
assert (
self.AUTHENTICATION_METHOD == self.AuthenticationMethod.USERNAME
) or self.UNIQUE_EMAIL
assert (
self.EMAIL_VERIFICATION != self.EmailVerificationMethod.MANDATORY
) or self.EMAIL_REQUIRED
if not self.USER_MODEL_USERNAME_FIELD:
assert not self.USERNAME_REQUIRED
assert self.AUTHENTICATION_METHOD not in (
self.AuthenticationMethod.USERNAME,
self.AuthenticationMethod.USERNAME_EMAIL,
)
if self.MAX_EMAIL_ADDRESSES is not None:
assert self.MAX_EMAIL_ADDRESSES > 0
if self.CHANGE_EMAIL:
if self.MAX_EMAIL_ADDRESSES is not None and self.MAX_EMAIL_ADDRESSES != 2:
raise ImproperlyConfigured(
"Invalid combination of ACCOUNT_CHANGE_EMAIL and ACCOUNT_MAX_EMAIL_ADDRESSES"
)
if hasattr(settings, "ACCOUNT_LOGIN_ATTEMPTS_LIMIT") or hasattr(
settings, "ACCOUNT_LOGIN_ATTEMPTS_TIMEOUT"
):
warnings.warn(
"settings.ACCOUNT_LOGIN_ATTEMPTS_LIMIT/TIMEOUT is deprecated, use: settings.ACCOUNT_RATE_LIMITS['login_failed']"
)
if hasattr(settings, "ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN"):
warnings.warn(
"settings.ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN is deprecated, use: settings.ACCOUNT_RATE_LIMITS['confirm_email']"
)
def _setting(self, name, dflt):
from allauth.utils import get_setting
return get_setting(self.prefix + name, dflt)
@property
def PREVENT_ENUMERATION(self):
return self._setting("PREVENT_ENUMERATION", True)
@property
def DEFAULT_HTTP_PROTOCOL(self):
return self._setting("DEFAULT_HTTP_PROTOCOL", "http").lower()
@property
def EMAIL_CONFIRMATION_EXPIRE_DAYS(self):
"""
Determines the expiration date of email confirmation mails (#
of days)
"""
from django.conf import settings
return self._setting(
"EMAIL_CONFIRMATION_EXPIRE_DAYS",
getattr(settings, "EMAIL_CONFIRMATION_DAYS", 3),
)
@property
def EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL(self):
"""
The URL to redirect to after a successful email confirmation, in
case of an authenticated user
"""
return self._setting("EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL", None)
@property
def EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL(self):
"""
The URL to redirect to after a successful email confirmation, in
case no user is logged in
"""
from django.conf import settings
return self._setting(
"EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL", settings.LOGIN_URL
)
@property
def EMAIL_REQUIRED(self):
"""
The user is required to hand over an email address when signing up
"""
return self._setting("EMAIL_REQUIRED", False)
@property
def EMAIL_VERIFICATION(self):
"""
See email verification method
"""
ret = self._setting("EMAIL_VERIFICATION", self.EmailVerificationMethod.OPTIONAL)
# Deal with legacy (boolean based) setting
if ret is True:
ret = self.EmailVerificationMethod.MANDATORY
elif ret is False:
ret = self.EmailVerificationMethod.OPTIONAL
return self.EmailVerificationMethod(ret)
@property
def EMAIL_VERIFICATION_BY_CODE_ENABLED(self):
return self._setting("EMAIL_VERIFICATION_BY_CODE_ENABLED", False)
@property
def EMAIL_VERIFICATION_BY_CODE_MAX_ATTEMPTS(self):
return self._setting("EMAIL_VERIFICATION_BY_CODE_MAX_ATTEMPTS", 3)
@property
def EMAIL_VERIFICATION_BY_CODE_TIMEOUT(self):
return self._setting("EMAIL_VERIFICATION_BY_CODE_TIMEOUT", 15 * 60)
@property
def MAX_EMAIL_ADDRESSES(self):
return self._setting("MAX_EMAIL_ADDRESSES", None)
@property
def CHANGE_EMAIL(self):
return self._setting("CHANGE_EMAIL", False)
@property
def AUTHENTICATION_METHOD(self):
ret = self._setting("AUTHENTICATION_METHOD", self.AuthenticationMethod.USERNAME)
return self.AuthenticationMethod(ret)
@property
def EMAIL_MAX_LENGTH(self):
"""
Adjust max_length of email addresses
"""
return self._setting("EMAIL_MAX_LENGTH", 254)
@property
def UNIQUE_EMAIL(self):
"""
Enforce uniqueness of email addresses
"""
return self._setting("UNIQUE_EMAIL", True)
@property
def SIGNUP_EMAIL_ENTER_TWICE(self):
"""
Signup email verification
"""
return self._setting("SIGNUP_EMAIL_ENTER_TWICE", False)
@property
def SIGNUP_PASSWORD_ENTER_TWICE(self):
"""
Signup password verification
"""
legacy = self._setting("SIGNUP_PASSWORD_VERIFICATION", True)
return self._setting("SIGNUP_PASSWORD_ENTER_TWICE", legacy)
@property
def SIGNUP_REDIRECT_URL(self):
from django.conf import settings
return self._setting("SIGNUP_REDIRECT_URL", settings.LOGIN_REDIRECT_URL)
@property
def PASSWORD_MIN_LENGTH(self):
"""
Minimum password Length
"""
from django.conf import settings
ret = None
if not settings.AUTH_PASSWORD_VALIDATORS:
ret = self._setting("PASSWORD_MIN_LENGTH", 6)
return ret
@property
def RATE_LIMITS(self):
rls = self._setting("RATE_LIMITS", {})
if rls is False:
return {}
attempts_amount = self._setting("LOGIN_ATTEMPTS_LIMIT", 5)
attempts_timeout = self._setting("LOGIN_ATTEMPTS_TIMEOUT", 60 * 5)
login_failed_rl = None
if attempts_amount and attempts_timeout:
login_failed_rl = f"10/m/ip,{attempts_amount}/{attempts_timeout}s/key"
if self.EMAIL_VERIFICATION_BY_CODE_ENABLED:
confirm_email_rl = "1/10s/key"
else:
cooldown = self._setting("EMAIL_CONFIRMATION_COOLDOWN", 3 * 60)
confirm_email_rl = None
if cooldown:
confirm_email_rl = f"1/{cooldown}s/key"
ret = {
# Change password view (for users already logged in)
"change_password": "5/m/user",
# Email management (e.g. add, remove, change primary)
"manage_email": "10/m/user",
# Request a password reset, global rate limit per IP
"reset_password": "20/m/ip,5/m/key",
# Reauthentication for users already logged in
"reauthenticate": "10/m/user",
# Password reset (the view the password reset email links to).
"reset_password_from_key": "20/m/ip",
# Signups.
"signup": "20/m/ip",
# Logins.
"login": "30/m/ip",
# Request a login code: key is the email.
"request_login_code": "20/m/ip,3/m/key",
# Logins.
"login_failed": login_failed_rl,
# Confirm email
"confirm_email": confirm_email_rl,
}
ret.update(rls)
return ret
@property
def EMAIL_SUBJECT_PREFIX(self):
"""
Subject-line prefix to use for email messages sent
"""
return self._setting("EMAIL_SUBJECT_PREFIX", None)
@property
def SIGNUP_FORM_CLASS(self):
"""
Signup form
"""
return self._setting("SIGNUP_FORM_CLASS", None)
@property
def SIGNUP_FORM_HONEYPOT_FIELD(self):
"""
Honeypot field name. Empty string or ``None`` will disable honeypot behavior.
"""
return self._setting("SIGNUP_FORM_HONEYPOT_FIELD", None)
@property
def USERNAME_REQUIRED(self):
"""
The user is required to enter a username when signing up
"""
return self._setting("USERNAME_REQUIRED", True)
@property
def USERNAME_MIN_LENGTH(self):
"""
Minimum username Length
"""
return self._setting("USERNAME_MIN_LENGTH", 1)
@property
def USERNAME_BLACKLIST(self):
"""
List of usernames that are not allowed
"""
return self._setting("USERNAME_BLACKLIST", [])
@property
def PASSWORD_INPUT_RENDER_VALUE(self):
"""
render_value parameter as passed to PasswordInput fields
"""
return self._setting("PASSWORD_INPUT_RENDER_VALUE", False)
@property
def ADAPTER(self):
return self._setting("ADAPTER", "allauth.account.adapter.DefaultAccountAdapter")
@property
def CONFIRM_EMAIL_ON_GET(self):
return self._setting("CONFIRM_EMAIL_ON_GET", False)
@property
def AUTHENTICATED_LOGIN_REDIRECTS(self):
return self._setting("AUTHENTICATED_LOGIN_REDIRECTS", True)
@property
def LOGIN_ON_EMAIL_CONFIRMATION(self):
"""
Automatically log the user in once they confirmed their email address
"""
return self._setting("LOGIN_ON_EMAIL_CONFIRMATION", False)
@property
def LOGIN_ON_PASSWORD_RESET(self):
"""
Automatically log the user in immediately after resetting
their password.
"""
return self._setting("LOGIN_ON_PASSWORD_RESET", False)
@property
def LOGOUT_REDIRECT_URL(self):
from django.conf import settings
return self._setting("LOGOUT_REDIRECT_URL", settings.LOGOUT_REDIRECT_URL or "/")
@property
def LOGOUT_ON_GET(self):
return self._setting("LOGOUT_ON_GET", False)
@property
def LOGOUT_ON_PASSWORD_CHANGE(self):
return self._setting("LOGOUT_ON_PASSWORD_CHANGE", False)
@property
def USER_MODEL_USERNAME_FIELD(self):
return self._setting("USER_MODEL_USERNAME_FIELD", "username")
@property
def USER_MODEL_EMAIL_FIELD(self):
return self._setting("USER_MODEL_EMAIL_FIELD", "email")
@property
def SESSION_COOKIE_AGE(self):
"""
Deprecated -- use Django's settings.SESSION_COOKIE_AGE instead
"""
from django.conf import settings
return self._setting("SESSION_COOKIE_AGE", settings.SESSION_COOKIE_AGE)
@property
def SESSION_REMEMBER(self):
"""
Controls the life time of the session. Set to `None` to ask the user
("Remember me?"), `False` to not remember, and `True` to always
remember.
"""
return self._setting("SESSION_REMEMBER", None)
@property
def TEMPLATE_EXTENSION(self):
"""
A string defining the template extension to use, defaults to `html`.
"""
return self._setting("TEMPLATE_EXTENSION", "html")
@property
def FORMS(self):
return self._setting("FORMS", {})
@property
def EMAIL_CONFIRMATION_HMAC(self):
return self._setting("EMAIL_CONFIRMATION_HMAC", True)
@property
def SALT(self):
return self._setting("SALT", "account")
@property
def PRESERVE_USERNAME_CASING(self):
return self._setting("PRESERVE_USERNAME_CASING", True)
@property
def USERNAME_VALIDATORS(self):
from django.contrib.auth import get_user_model
from django.core.exceptions import ImproperlyConfigured
from allauth.utils import import_attribute
path = self._setting("USERNAME_VALIDATORS", None)
if path:
ret = import_attribute(path)
if not isinstance(ret, list):
raise ImproperlyConfigured(
"ACCOUNT_USERNAME_VALIDATORS is expected to be a list"
)
else:
if self.USER_MODEL_USERNAME_FIELD is not None:
ret = (
get_user_model()
._meta.get_field(self.USER_MODEL_USERNAME_FIELD)
.validators
)
else:
ret = []
return ret
@property
def PASSWORD_RESET_TOKEN_GENERATOR(self):
from allauth.account.forms import EmailAwarePasswordResetTokenGenerator
from allauth.utils import import_attribute
token_generator_path = self._setting("PASSWORD_RESET_TOKEN_GENERATOR", None)
if token_generator_path is not None:
token_generator = import_attribute(token_generator_path)
else:
token_generator = EmailAwarePasswordResetTokenGenerator
return token_generator
@property
def EMAIL_UNKNOWN_ACCOUNTS(self):
return self._setting("EMAIL_UNKNOWN_ACCOUNTS", True)
@property
def REAUTHENTICATION_TIMEOUT(self):
return self._setting("REAUTHENTICATION_TIMEOUT", 300)
@property
def EMAIL_NOTIFICATIONS(self):
return self._setting("EMAIL_NOTIFICATIONS", False)
@property
def REAUTHENTICATION_REQUIRED(self):
return self._setting("REAUTHENTICATION_REQUIRED", False)
@property
def LOGIN_BY_CODE_ENABLED(self):
return self._setting("LOGIN_BY_CODE_ENABLED", False)
@property
def LOGIN_BY_CODE_MAX_ATTEMPTS(self):
return self._setting("LOGIN_BY_CODE_MAX_ATTEMPTS", 3)
@property
def LOGIN_BY_CODE_TIMEOUT(self):
return self._setting("LOGIN_BY_CODE_TIMEOUT", 3 * 60)
@property
def LOGIN_TIMEOUT(self):
"""
The maximum allowed time (in seconds) for a login to go through the
various login stages. This limits, for example, the time span that the
2FA stage remains available.
"""
return self._setting("LOGIN_TIMEOUT", 15 * 60)
@property
def LOGIN_BY_CODE_REQUIRED(self) -> Union[bool, Set[str]]:
"""
When enabled (in case of ``True``), every user logging in is
required to input a login confirmation code sent by email.
Alternatively, you can specify a set of authentication methods
(``"password"``, ``"mfa"``, or ``"socialaccount"``) for which login
codes are required.
"""
value = self._setting("LOGIN_BY_CODE_REQUIRED", False)
if isinstance(value, bool):
return value
return set(value)
_app_settings = AppSettings("ACCOUNT_")
def __getattr__(name):
# See https://peps.python.org/pep-0562/
return getattr(_app_settings, name)

View File

@@ -0,0 +1,21 @@
from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext_lazy as _
from allauth import app_settings
class AccountConfig(AppConfig):
name = "allauth.account"
verbose_name = _("Accounts")
default_auto_field = app_settings.DEFAULT_AUTO_FIELD or "django.db.models.AutoField"
def ready(self):
from allauth.account import checks # noqa
required_mw = "allauth.account.middleware.AccountMiddleware"
if required_mw not in settings.MIDDLEWARE:
raise ImproperlyConfigured(
f"{required_mw} must be added to settings.MIDDLEWARE"
)

View File

@@ -0,0 +1,99 @@
from threading import local
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from . import app_settings
from .app_settings import AuthenticationMethod
from .utils import filter_users_by_email, filter_users_by_username
_stash = local()
class AuthenticationBackend(ModelBackend):
def authenticate(self, request, **credentials):
ret = None
if app_settings.AUTHENTICATION_METHOD == AuthenticationMethod.EMAIL:
ret = self._authenticate_by_email(**credentials)
elif app_settings.AUTHENTICATION_METHOD == AuthenticationMethod.USERNAME_EMAIL:
ret = self._authenticate_by_email(**credentials)
if not ret:
ret = self._authenticate_by_username(**credentials)
else:
ret = self._authenticate_by_username(**credentials)
return ret
def _authenticate_by_username(self, **credentials):
username_field = app_settings.USER_MODEL_USERNAME_FIELD
username = credentials.get("username")
password = credentials.get("password")
User = get_user_model()
if not username_field or username is None or password is None:
return None
try:
# Username query is case insensitive
user = filter_users_by_username(username).get()
except User.DoesNotExist:
# Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user.
get_user_model()().set_password(password)
return None
else:
if self._check_password(user, password):
return user
def _authenticate_by_email(self, **credentials):
# Even though allauth will pass along `email`, other apps may
# not respect this setting. For example, when using
# django-tastypie basic authentication, the login is always
# passed as `username`. So let's play nice with other apps
# and use username as fallback
email = credentials.get("email", credentials.get("username"))
if email:
for user in filter_users_by_email(email, prefer_verified=True):
if self._check_password(user, credentials["password"]):
return user
return None
def _check_password(self, user, password):
ret = user.check_password(password)
if ret:
ret = self.user_can_authenticate(user)
if not ret:
self._stash_user(user)
return ret
@classmethod
def _stash_user(cls, user):
"""Now, be aware, the following is quite ugly, let me explain:
Even if the user credentials match, the authentication can fail because
Django's default ModelBackend calls user_can_authenticate(), which
checks `is_active`. Now, earlier versions of allauth did not do this
and simply returned the user as authenticated, even in case of
`is_active=False`. For allauth scope, this does not pose a problem, as
these users are properly redirected to an account inactive page.
This does pose a problem when the allauth backend is used in a
different context where allauth is not responsible for the login. Then,
by not checking on `user_can_authenticate()` users will allow to become
authenticated whereas according to Django logic this should not be
allowed.
In order to preserve the allauth behavior while respecting Django's
logic, we stash a user for which the password check succeeded but
`user_can_authenticate()` failed. In the allauth authentication logic,
we can then unstash this user and proceed pointing the user to the
account inactive page.
"""
global _stash
ret = getattr(_stash, "user", None)
_stash.user = user
return ret
@classmethod
def unstash_authenticated_user(cls):
return cls._stash_user(None)

View File

@@ -0,0 +1,7 @@
from allauth.account.internal.flows.login import (
AUTHENTICATION_METHODS_SESSION_KEY,
)
def get_authentication_records(request):
return request.session.get(AUTHENTICATION_METHODS_SESSION_KEY, [])

View File

@@ -0,0 +1,52 @@
from django.core.checks import Critical, Warning, register
@register()
def adapter_check(app_configs, **kwargs):
from allauth.account.adapter import get_adapter
ret = []
adapter = get_adapter()
if hasattr(adapter, "get_email_confirmation_redirect_url"):
ret.append(
Warning(
msg="adapter.get_email_confirmation_redirect_url(request) is deprecated, use adapter.get_email_verification_redirect_url(email_address)"
)
)
return ret
@register()
def settings_check(app_configs, **kwargs):
from allauth import app_settings as allauth_app_settings
from allauth.account import app_settings
ret = []
if allauth_app_settings.SOCIALACCOUNT_ONLY:
if app_settings.LOGIN_BY_CODE_ENABLED:
ret.append(
Critical(
msg="SOCIALACCOUNT_ONLY does not work with ACCOUNT_LOGIN_BY_CODE_ENABLED"
)
)
if allauth_app_settings.MFA_ENABLED:
ret.append(
Critical(msg="SOCIALACCOUNT_ONLY does not work with 'allauth.mfa'")
)
if app_settings.EMAIL_VERIFICATION != app_settings.EmailVerificationMethod.NONE:
ret.append(
Critical(
msg="SOCIALACCOUNT_ONLY requires ACCOUNT_EMAIL_VERIFICATION = 'none'"
)
)
if (
app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED
and app_settings.EMAIL_VERIFICATION
!= app_settings.EmailVerificationMethod.MANDATORY
):
ret.append(
Critical(
msg="ACCOUNT_EMAIL_VERFICATION_BY_CODE requires ACCOUNT_EMAIL_VERIFICATION = 'mandatory'"
)
)
return ret

View File

@@ -0,0 +1,102 @@
from functools import wraps
from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect
from django.shortcuts import render, resolve_url
from allauth.account.internal.flows import reauthentication
from allauth.account.models import EmailAddress
from allauth.account.utils import (
get_next_redirect_url,
send_email_confirmation,
)
from allauth.core.exceptions import ReauthenticationRequired
from allauth.core.internal import httpkit
def verified_email_required(
function=None, login_url=None, redirect_field_name=REDIRECT_FIELD_NAME
):
"""
Even when email verification is not mandatory during signup, there
may be circumstances during which you really want to prevent
unverified users to proceed. This decorator ensures the user is
authenticated and has a verified email address. If the former is
not the case then the behavior is identical to that of the
standard `login_required` decorator. If the latter does not hold,
email verification mails are automatically resend and the user is
presented with a page informing them they needs to verify their email
address.
"""
def decorator(view_func):
@login_required(redirect_field_name=redirect_field_name, login_url=login_url)
def _wrapped_view(request, *args, **kwargs):
if not EmailAddress.objects.filter(
user=request.user, verified=True
).exists():
send_email_confirmation(request, request.user)
return render(request, "account/verified_email_required.html")
return view_func(request, *args, **kwargs)
return _wrapped_view
if function:
return decorator(function)
return decorator
def reauthentication_required(
function=None,
redirect_field_name=REDIRECT_FIELD_NAME,
allow_get=False,
enabled=None,
):
def decorator(view_func):
@wraps(view_func)
def _wrapper_view(request, *args, **kwargs):
pass_method = allow_get and request.method == "GET"
ena = (enabled is None) or (
enabled(request) if callable(enabled) else enabled
)
if ena and not pass_method:
if (
request.user.is_anonymous
or not reauthentication.did_recently_authenticate(request)
):
raise ReauthenticationRequired()
return view_func(request, *args, **kwargs)
return _wrapper_view
if function:
return decorator(function)
return decorator
def secure_admin_login(function=None):
def decorator(view_func):
@wraps(view_func)
def _wrapper_view(request, *args, **kwargs):
if request.user.is_authenticated:
if not request.user.is_staff or not request.user.is_active:
raise PermissionDenied()
return view_func(request, *args, **kwargs)
else:
next_url = get_next_redirect_url(request)
if not next_url:
next_url = request.get_full_path()
login_url = resolve_url(settings.LOGIN_URL)
login_url = httpkit.add_query_params(
login_url, {REDIRECT_FIELD_NAME: next_url}
)
return HttpResponseRedirect(login_url)
return _wrapper_view
if function:
return decorator(function)
return decorator

View File

@@ -0,0 +1,747 @@
from importlib import import_module
from django import forms
from django.contrib.auth import get_user_model, password_validation
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.core import exceptions, validators
from django.urls import NoReverseMatch, reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext, gettext_lazy as _, pgettext
from allauth.account.internal import flows
from allauth.account.internal.stagekit import LOGIN_SESSION_KEY
from allauth.account.stages import EmailVerificationStage
from allauth.core import context, ratelimit
from allauth.utils import get_username_max_length, set_form_field_order
from . import app_settings
from .adapter import DefaultAccountAdapter, get_adapter
from .app_settings import AuthenticationMethod
from .models import EmailAddress, Login
from .utils import (
filter_users_by_email,
setup_user_email,
sync_user_email_addresses,
url_str_to_user_pk,
user_email,
user_pk_to_url_str,
user_username,
)
class EmailAwarePasswordResetTokenGenerator(PasswordResetTokenGenerator):
def _make_hash_value(self, user, timestamp):
ret = super(EmailAwarePasswordResetTokenGenerator, self)._make_hash_value(
user, timestamp
)
sync_user_email_addresses(user)
email = user_email(user)
emails = set([email] if email else [])
emails.update(
EmailAddress.objects.filter(user=user).values_list("email", flat=True)
)
ret += "|".join(sorted(emails))
return ret
default_token_generator = app_settings.PASSWORD_RESET_TOKEN_GENERATOR()
class PasswordVerificationMixin:
def clean(self):
cleaned_data = super(PasswordVerificationMixin, self).clean()
password1 = cleaned_data.get("password1")
password2 = cleaned_data.get("password2")
if (password1 and password2) and password1 != password2:
self.add_error("password2", _("You must type the same password each time."))
return cleaned_data
class PasswordField(forms.CharField):
def __init__(self, *args, **kwargs):
render_value = kwargs.pop(
"render_value", app_settings.PASSWORD_INPUT_RENDER_VALUE
)
kwargs["widget"] = forms.PasswordInput(
render_value=render_value,
attrs={"placeholder": kwargs.get("label")},
)
autocomplete = kwargs.pop("autocomplete", None)
if autocomplete is not None:
kwargs["widget"].attrs["autocomplete"] = autocomplete
super(PasswordField, self).__init__(*args, **kwargs)
class SetPasswordField(PasswordField):
def __init__(self, *args, **kwargs):
kwargs["autocomplete"] = "new-password"
kwargs.setdefault(
"help_text", password_validation.password_validators_help_text_html()
)
super().__init__(*args, **kwargs)
self.user = None
def clean(self, value):
value = super().clean(value)
value = get_adapter().clean_password(value, user=self.user)
return value
class LoginForm(forms.Form):
password = PasswordField(label=_("Password"), autocomplete="current-password")
remember = forms.BooleanField(label=_("Remember Me"), required=False)
user = None
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
super(LoginForm, self).__init__(*args, **kwargs)
if app_settings.AUTHENTICATION_METHOD == AuthenticationMethod.EMAIL:
login_widget = forms.EmailInput(
attrs={
"placeholder": _("Email address"),
"autocomplete": "email",
}
)
login_field = forms.EmailField(label=_("Email"), widget=login_widget)
elif app_settings.AUTHENTICATION_METHOD == AuthenticationMethod.USERNAME:
login_widget = forms.TextInput(
attrs={"placeholder": _("Username"), "autocomplete": "username"}
)
login_field = forms.CharField(
label=_("Username"),
widget=login_widget,
max_length=get_username_max_length(),
)
else:
assert (
app_settings.AUTHENTICATION_METHOD
== AuthenticationMethod.USERNAME_EMAIL
)
login_widget = forms.TextInput(
attrs={"placeholder": _("Username or email"), "autocomplete": "email"}
)
login_field = forms.CharField(
label=pgettext("field label", "Login"), widget=login_widget
)
self.fields["login"] = login_field
set_form_field_order(self, ["login", "password", "remember"])
if app_settings.SESSION_REMEMBER is not None:
del self.fields["remember"]
try:
reset_url = reverse("account_reset_password")
except NoReverseMatch:
pass
else:
forgot_txt = _("Forgot your password?")
self.fields["password"].help_text = mark_safe(
f'<a href="{reset_url}">{forgot_txt}</a>'
)
def user_credentials(self):
"""
Provides the credentials required to authenticate the user for
login.
"""
credentials = {}
login = self.cleaned_data["login"]
if app_settings.AUTHENTICATION_METHOD == AuthenticationMethod.EMAIL:
credentials["email"] = login
elif app_settings.AUTHENTICATION_METHOD == AuthenticationMethod.USERNAME:
credentials["username"] = login
else:
if self._is_login_email(login):
credentials["email"] = login
credentials["username"] = login
credentials["password"] = self.cleaned_data["password"]
return credentials
def clean_login(self):
login = self.cleaned_data["login"]
return login.strip()
def _is_login_email(self, login):
try:
validators.validate_email(login)
ret = True
except exceptions.ValidationError:
ret = False
return ret
def clean(self):
super(LoginForm, self).clean()
if self._errors:
return
credentials = self.user_credentials()
adapter = get_adapter(self.request)
user = adapter.authenticate(self.request, **credentials)
if user:
login = Login(user=user, email=credentials.get("email"))
if flows.login.is_login_rate_limited(context.request, login):
raise adapter.validation_error("too_many_login_attempts")
self._login = login
self.user = user
else:
auth_method = app_settings.AUTHENTICATION_METHOD
if auth_method == app_settings.AuthenticationMethod.USERNAME_EMAIL:
login = self.cleaned_data["login"]
if self._is_login_email(login):
auth_method = app_settings.AuthenticationMethod.EMAIL
else:
auth_method = app_settings.AuthenticationMethod.USERNAME
raise adapter.validation_error("%s_password_mismatch" % auth_method.value)
return self.cleaned_data
def login(self, request, redirect_url=None):
credentials = self.user_credentials()
login = self._login
login.redirect_url = redirect_url
ret = flows.login.perform_password_login(request, credentials, login)
remember = app_settings.SESSION_REMEMBER
if remember is None:
remember = self.cleaned_data["remember"]
if remember:
request.session.set_expiry(app_settings.SESSION_COOKIE_AGE)
else:
request.session.set_expiry(0)
return ret
class _DummyCustomSignupForm(forms.Form):
def signup(self, request, user):
"""
Invoked at signup time to complete the signup of the user.
"""
pass
def _base_signup_form_class():
"""
Currently, we inherit from the custom form, if any. This is all
not very elegant, though it serves a purpose:
- There are two signup forms: one for local accounts, and one for
social accounts
- Both share a common base (BaseSignupForm)
- Given the above, how to put in a custom signup form? Which form
would your custom form derive from, the local or the social one?
"""
if not app_settings.SIGNUP_FORM_CLASS:
return _DummyCustomSignupForm
try:
fc_module, fc_classname = app_settings.SIGNUP_FORM_CLASS.rsplit(".", 1)
except ValueError:
raise exceptions.ImproperlyConfigured(
"%s does not point to a form class" % app_settings.SIGNUP_FORM_CLASS
)
try:
mod = import_module(fc_module)
except ImportError as e:
raise exceptions.ImproperlyConfigured(
"Error importing form class %s:" ' "%s"' % (fc_module, e)
)
try:
fc_class = getattr(mod, fc_classname)
except AttributeError:
raise exceptions.ImproperlyConfigured(
'Module "%s" does not define a' ' "%s" class' % (fc_module, fc_classname)
)
if not hasattr(fc_class, "signup"):
raise exceptions.ImproperlyConfigured(
"The custom signup form must offer"
" a `def signup(self, request, user)` method",
)
return fc_class
class BaseSignupForm(_base_signup_form_class()): # type: ignore[misc]
username = forms.CharField(
label=_("Username"),
min_length=app_settings.USERNAME_MIN_LENGTH,
widget=forms.TextInput(
attrs={"placeholder": _("Username"), "autocomplete": "username"}
),
)
email = forms.EmailField(
widget=forms.TextInput(
attrs={
"type": "email",
"placeholder": _("Email address"),
"autocomplete": "email",
}
)
)
def __init__(self, *args, **kwargs):
email_required = kwargs.pop("email_required", app_settings.EMAIL_REQUIRED)
self.username_required = kwargs.pop(
"username_required", app_settings.USERNAME_REQUIRED
)
self.account_already_exists = False
super(BaseSignupForm, self).__init__(*args, **kwargs)
username_field = self.fields["username"]
username_field.max_length = get_username_max_length()
username_field.validators.append(
validators.MaxLengthValidator(username_field.max_length)
)
username_field.widget.attrs["maxlength"] = str(username_field.max_length)
default_field_order = [
"email",
"email2", # ignored when not present
"username",
"password1",
"password2", # ignored when not present
]
if app_settings.SIGNUP_EMAIL_ENTER_TWICE:
self.fields["email2"] = forms.EmailField(
label=_("Email (again)"),
widget=forms.TextInput(
attrs={
"type": "email",
"placeholder": _("Email address confirmation"),
}
),
)
if email_required:
self.fields["email"].label = gettext("Email")
self.fields["email"].required = True
else:
self.fields["email"].label = gettext("Email (optional)")
self.fields["email"].required = False
self.fields["email"].widget.is_required = False
if self.username_required:
default_field_order = [
"username",
"email",
"email2", # ignored when not present
"password1",
"password2", # ignored when not present
]
if not self.username_required:
del self.fields["username"]
set_form_field_order(
self, getattr(self, "field_order", None) or default_field_order
)
def clean_username(self):
value = self.cleaned_data["username"]
value = get_adapter().clean_username(value)
# Note regarding preventing enumeration: if the username is already
# taken, but the email address is not, we would still leak information
# if we were to send an email to that email address stating that the
# username is already in use.
return value
def clean_email(self):
value = self.cleaned_data["email"].lower()
value = get_adapter().clean_email(value)
if value and app_settings.UNIQUE_EMAIL:
value = self.validate_unique_email(value)
return value
def clean_email2(self):
value = self.cleaned_data["email2"].lower()
return value
def validate_unique_email(self, value):
adapter = get_adapter()
assessment = flows.manage_email.assess_unique_email(value)
if assessment is True:
# All good.
pass
elif assessment is False:
# Fail right away.
raise adapter.validation_error("email_taken")
else:
assert assessment is None
self.account_already_exists = True
return adapter.validate_unique_email(value)
def clean(self):
cleaned_data = super(BaseSignupForm, self).clean()
if app_settings.SIGNUP_EMAIL_ENTER_TWICE:
email = cleaned_data.get("email")
email2 = cleaned_data.get("email2")
if (email and email2) and email != email2:
self.add_error("email2", _("You must type the same email each time."))
return cleaned_data
def custom_signup(self, request, user):
self.signup(request, user)
def try_save(self, request):
"""Try and save the user. This can fail in case of a conflict on the
email address, in that case we will send an "account already exists"
email and return a standard "email verification sent" response.
"""
if self.account_already_exists:
# Don't create a new account, only send an email informing the user
# that (s)he already has one...
email = self.cleaned_data["email"]
resp = flows.signup.prevent_enumeration(request, email)
user = None
# Fake a login stage.
request.session[LOGIN_SESSION_KEY] = EmailVerificationStage.key
else:
user = self.save(request)
resp = None
return user, resp
def save(self, request):
email = self.cleaned_data.get("email")
if self.account_already_exists:
raise ValueError(email)
adapter = get_adapter()
user = adapter.new_user(request)
adapter.save_user(request, user, self)
self.custom_signup(request, user)
# TODO: Move into adapter `save_user` ?
setup_user_email(request, user, [EmailAddress(email=email)] if email else [])
return user
class SignupForm(BaseSignupForm):
def __init__(self, *args, **kwargs):
self.by_passkey = kwargs.pop("by_passkey", False)
super(SignupForm, self).__init__(*args, **kwargs)
if not self.by_passkey:
self.fields["password1"] = PasswordField(
label=_("Password"),
autocomplete="new-password",
help_text=password_validation.password_validators_help_text_html(),
)
if app_settings.SIGNUP_PASSWORD_ENTER_TWICE:
self.fields["password2"] = PasswordField(
label=_("Password (again)"), autocomplete="new-password"
)
if hasattr(self, "field_order"):
set_form_field_order(self, self.field_order)
honeypot_field_name = app_settings.SIGNUP_FORM_HONEYPOT_FIELD
if honeypot_field_name:
self.fields[honeypot_field_name] = forms.CharField(
required=False,
label="",
widget=forms.TextInput(
attrs={
"style": "position: absolute; right: -99999px;",
"tabindex": "-1",
"autocomplete": "nope",
}
),
)
def try_save(self, request):
"""
override of parent class method that adds additional catching
of a potential bot filling out the honeypot field and returns a
'fake' email verification response if honeypot was filled out
"""
honeypot_field_name = app_settings.SIGNUP_FORM_HONEYPOT_FIELD
if honeypot_field_name:
if self.cleaned_data[honeypot_field_name]:
user = None
adapter = get_adapter()
# honeypot fields work best when you do not report to the bot
# that anything went wrong. So we return a fake email verification
# sent response but without creating a user
resp = adapter.respond_email_verification_sent(request, None)
return user, resp
return super().try_save(request)
def clean(self):
super().clean()
# `password` cannot be of type `SetPasswordField`, as we don't
# have a `User` yet. So, let's populate a dummy user to be used
# for password validation.
User = get_user_model()
dummy_user = User()
user_username(dummy_user, self.cleaned_data.get("username"))
user_email(dummy_user, self.cleaned_data.get("email"))
password = self.cleaned_data.get("password1")
if password:
try:
get_adapter().clean_password(password, user=dummy_user)
except forms.ValidationError as e:
self.add_error("password1", e)
if (
app_settings.SIGNUP_PASSWORD_ENTER_TWICE
and "password1" in self.cleaned_data
and "password2" in self.cleaned_data
):
if self.cleaned_data["password1"] != self.cleaned_data["password2"]:
self.add_error(
"password2",
_("You must type the same password each time."),
)
return self.cleaned_data
class UserForm(forms.Form):
def __init__(self, user=None, *args, **kwargs):
self.user = user
super(UserForm, self).__init__(*args, **kwargs)
class AddEmailForm(UserForm):
email = forms.EmailField(
label=_("Email"),
required=True,
widget=forms.TextInput(
attrs={"type": "email", "placeholder": _("Email address")}
),
)
def clean_email(self):
from allauth.account import signals
value = self.cleaned_data["email"].lower()
adapter = get_adapter()
value = adapter.clean_email(value)
users = filter_users_by_email(value)
on_this_account = [u for u in users if u.pk == self.user.pk]
on_diff_account = [u for u in users if u.pk != self.user.pk]
if on_this_account:
raise adapter.validation_error("duplicate_email")
if (
# Email is taken by a different account...
on_diff_account
# We care about not having duplicate emails
and app_settings.UNIQUE_EMAIL
# Enumeration prevention is turned off.
and (not app_settings.PREVENT_ENUMERATION)
):
raise adapter.validation_error("email_taken")
if not EmailAddress.objects.can_add_email(self.user):
raise adapter.validation_error(
"max_email_addresses", app_settings.MAX_EMAIL_ADDRESSES
)
signals._add_email.send(
sender=self.user.__class__,
email=value,
user=self.user,
)
return value
def save(self, request):
if app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED:
email_address = EmailAddress(
user=self.user, email=self.cleaned_data["email"]
)
email_address.send_confirmation(request)
return email_address
elif app_settings.CHANGE_EMAIL:
return EmailAddress.objects.add_new_email(
request, self.user, self.cleaned_data["email"]
)
return EmailAddress.objects.add_email(
request, self.user, self.cleaned_data["email"], confirm=True
)
class ChangePasswordForm(PasswordVerificationMixin, UserForm):
oldpassword = PasswordField(
label=_("Current Password"), autocomplete="current-password"
)
password1 = SetPasswordField(label=_("New Password"))
password2 = PasswordField(label=_("New Password (again)"))
def __init__(self, *args, **kwargs):
super(ChangePasswordForm, self).__init__(*args, **kwargs)
self.fields["password1"].user = self.user
def clean_oldpassword(self):
if not self.user.check_password(self.cleaned_data.get("oldpassword")):
raise get_adapter().validation_error("enter_current_password")
return self.cleaned_data["oldpassword"]
def save(self):
flows.password_change.change_password(self.user, self.cleaned_data["password1"])
class SetPasswordForm(PasswordVerificationMixin, UserForm):
password1 = SetPasswordField(label=_("Password"))
password2 = PasswordField(label=_("Password (again)"))
def __init__(self, *args, **kwargs):
super(SetPasswordForm, self).__init__(*args, **kwargs)
self.fields["password1"].user = self.user
def save(self):
flows.password_change.change_password(self.user, self.cleaned_data["password1"])
class ResetPasswordForm(forms.Form):
email = forms.EmailField(
label=_("Email"),
required=True,
widget=forms.TextInput(
attrs={
"type": "email",
"placeholder": _("Email address"),
"autocomplete": "email",
}
),
)
def clean_email(self):
email = self.cleaned_data["email"].lower()
email = get_adapter().clean_email(email)
self.users = filter_users_by_email(email, is_active=True, prefer_verified=True)
if not self.users and not app_settings.PREVENT_ENUMERATION:
raise get_adapter().validation_error("unknown_email")
return self.cleaned_data["email"]
def save(self, request, **kwargs) -> str:
email = self.cleaned_data["email"]
if not self.users:
flows.signup.send_unknown_account_mail(request, email)
return email
adapter: DefaultAccountAdapter = get_adapter()
token_generator = kwargs.get("token_generator", default_token_generator)
for user in self.users:
temp_key = token_generator.make_token(user)
# send the password reset email
uid = user_pk_to_url_str(user)
# We intentionally pass an opaque `key` on the interface here, and
# not implementation details such as a separate `uidb36` and
# `key. Ideally, this should have done on `urls` level as well.
key = f"{uid}-{temp_key}"
url = adapter.get_reset_password_from_key_url(key)
context = {
"user": user,
"password_reset_url": url,
"uid": uid,
"key": temp_key,
"request": request,
}
if app_settings.AUTHENTICATION_METHOD != AuthenticationMethod.EMAIL:
context["username"] = user_username(user)
adapter.send_password_reset_mail(user, email, context)
return email
class ResetPasswordKeyForm(PasswordVerificationMixin, forms.Form):
password1 = SetPasswordField(label=_("New Password"))
password2 = PasswordField(label=_("New Password (again)"))
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
self.temp_key = kwargs.pop("temp_key", None)
super(ResetPasswordKeyForm, self).__init__(*args, **kwargs)
self.fields["password1"].user = self.user
def save(self):
flows.password_reset.reset_password(self.user, self.cleaned_data["password1"])
class UserTokenForm(forms.Form):
uidb36 = forms.CharField()
key = forms.CharField()
reset_user = None
token_generator = default_token_generator
def _get_user(self, uidb36):
User = get_user_model()
try:
pk = url_str_to_user_pk(uidb36)
return User.objects.get(pk=pk)
except (ValueError, User.DoesNotExist):
return None
def clean(self):
cleaned_data = super(UserTokenForm, self).clean()
uidb36 = cleaned_data.get("uidb36", None)
key = cleaned_data.get("key", None)
adapter = get_adapter()
if not key:
raise adapter.validation_error("invalid_password_reset")
self.reset_user = self._get_user(uidb36)
if self.reset_user is None or not self.token_generator.check_token(
self.reset_user, key
):
raise adapter.validation_error("invalid_password_reset")
return cleaned_data
class ReauthenticateForm(forms.Form):
password = PasswordField(label=_("Password"), autocomplete="current-password")
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
def clean_password(self):
password = self.cleaned_data.get("password")
if not get_adapter().reauthenticate(self.user, password):
raise get_adapter().validation_error("incorrect_password")
return password
class RequestLoginCodeForm(forms.Form):
email = forms.EmailField(
widget=forms.EmailInput(
attrs={
"placeholder": _("Email address"),
"autocomplete": "email",
}
)
)
def clean_email(self):
adapter = get_adapter()
email = self.cleaned_data["email"]
if not app_settings.PREVENT_ENUMERATION:
users = filter_users_by_email(email, is_active=True, prefer_verified=True)
if not users:
raise adapter.validation_error("unknown_email")
if not ratelimit.consume(
context.request, action="request_login_code", key=email.lower()
):
raise adapter.validation_error("too_many_login_attempts")
return email
class BaseConfirmCodeForm(forms.Form):
code = forms.CharField(
label=_("Code"),
widget=forms.TextInput(
attrs={"placeholder": _("Code"), "autocomplete": "one-time-code"},
),
)
def __init__(self, *args, **kwargs):
self.code = kwargs.pop("code")
super().__init__(*args, **kwargs)
def clean_code(self):
code = self.cleaned_data.get("code")
if not flows.login_by_code.compare_code(actual=code, expected=self.code):
raise get_adapter().validation_error("incorrect_code")
return code
class ConfirmLoginCodeForm(BaseConfirmCodeForm):
pass
class ConfirmEmailVerificationCodeForm(BaseConfirmCodeForm):
pass

View File

@@ -0,0 +1,4 @@
from allauth.account.internal import flows
__all__ = ["flows"]

View File

@@ -0,0 +1,37 @@
from functools import wraps
from django.contrib.auth import decorators
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.views.decorators.cache import never_cache
from allauth.account.stages import LoginStageController
from allauth.account.utils import get_login_redirect_url
def _dummy_login_not_required(view_func):
return view_func
login_not_required = getattr(
decorators, "login_not_required", _dummy_login_not_required
)
def login_stage_required(stage: str, redirect_urlname: str):
def decorator(view_func):
@never_cache
@login_not_required
@wraps(view_func)
def _wrapper_view(request, *args, **kwargs):
if request.user.is_authenticated:
return HttpResponseRedirect(get_login_redirect_url(request))
login_stage = LoginStageController.enter(request, stage)
if not login_stage:
return HttpResponseRedirect(reverse(redirect_urlname))
request._login_stage = login_stage
return view_func(request, *args, **kwargs)
return _wrapper_view
return decorator

View File

@@ -0,0 +1,26 @@
from allauth.account.internal.flows import (
email_verification,
email_verification_by_code,
login,
login_by_code,
logout,
manage_email,
password_change,
password_reset,
reauthentication,
signup,
)
__all__ = [
"password_reset",
"password_change",
"email_verification",
"email_verification_by_code",
"login",
"login_by_code",
"logout",
"signup",
"manage_email",
"reauthentication",
]

View File

@@ -0,0 +1,250 @@
from typing import Optional, Tuple
from django.contrib import messages
from django.http import HttpRequest, HttpResponse
from django.urls import reverse
from allauth.account import app_settings, signals
from allauth.account.adapter import get_adapter
from allauth.account.internal.flows.manage_email import emit_email_changed
from allauth.account.models import EmailAddress, Login
from allauth.core import ratelimit
from allauth.core.exceptions import ImmediateHttpResponse
from allauth.core.internal.httpkit import get_frontend_url
from allauth.utils import build_absolute_uri
def verify_email_indirectly(request: HttpRequest, user, email: str) -> bool:
try:
email_address = EmailAddress.objects.get_for_user(user, email)
except EmailAddress.DoesNotExist:
return False
else:
if not email_address.verified:
return verify_email(request, email_address)
return True
def verify_email_and_resume(
request: HttpRequest, verification
) -> Tuple[Optional[EmailAddress], Optional[HttpResponse]]:
email_address = verification.confirm(request)
if not email_address:
return None, None
response = login_on_verification(request, verification)
return email_address, response
def verify_email(request: HttpRequest, email_address: EmailAddress) -> bool:
"""
Marks the email address as confirmed on the db
"""
added = not email_address.pk
from_email_address = (
EmailAddress.objects.filter(user_id=email_address.user_id)
.exclude(pk=email_address.pk)
.first()
)
if not email_address.set_verified(commit=False):
get_adapter(request).add_message(
request,
messages.ERROR,
"account/messages/email_confirmation_failed.txt",
{"email": email_address.email},
)
return False
email_address.set_as_primary(conditional=(not app_settings.CHANGE_EMAIL))
email_address.save()
if added:
signals.email_added.send(
sender=EmailAddress,
request=request,
user=request.user,
email_address=email_address,
)
signals.email_confirmed.send(
sender=EmailAddress,
request=request,
email_address=email_address,
)
if app_settings.CHANGE_EMAIL:
for instance in EmailAddress.objects.filter(
user_id=email_address.user_id
).exclude(pk=email_address.pk):
instance.remove()
emit_email_changed(request, from_email_address, email_address)
get_adapter(request).add_message(
request,
messages.SUCCESS,
"account/messages/email_confirmed.txt",
{"email": email_address.email},
)
return True
def get_email_verification_url(request: HttpRequest, emailconfirmation) -> str:
"""Constructs the email confirmation (activation) url.
Note that if you have architected your system such that email
confirmations are sent outside of the request context `request`
can be `None` here.
"""
url = get_frontend_url(request, "account_confirm_email", key=emailconfirmation.key)
if not url:
url = reverse("account_confirm_email", args=[emailconfirmation.key])
url = build_absolute_uri(request, url)
return url
def login_on_verification(request, verification) -> Optional[HttpResponse]:
"""Simply logging in the user may become a security issue. If you
do not take proper care (e.g. don't purge used email
confirmations), a malicious person that got hold of the link
will be able to login over and over again and the user is
unable to do anything about it. Even restoring their own mailbox
security will not help, as the links will still work. For
password reset this is different, this mechanism works only as
long as the attacker has access to the mailbox. If they no
longer has access they cannot issue a password request and
intercept it. Furthermore, all places where the links are
listed (log files, but even Google Analytics) all of a sudden
need to be secured. Purging the email confirmation once
confirmed changes the behavior -- users will not be able to
repeatedly confirm (in case they forgot that they already
clicked the mail).
All in all, we only login on verification when the user that is in the
process of signing up is present in the session to avoid all of the above.
This may not 100% work in case the user closes the browser (and the session
gets lost), but at least we're secure.
"""
from allauth.account.stages import (
EmailVerificationStage,
LoginStageController,
)
if not app_settings.LOGIN_ON_EMAIL_CONFIRMATION:
return None
if request.user.is_authenticated:
return None
stage = LoginStageController.enter(request, EmailVerificationStage.key)
if not stage or not stage.login.user:
return None
if stage.login.user.pk != verification.email_address.user_id:
return None
return stage.exit()
def consume_email_verification_rate_limit(
request: HttpRequest, email: str, dry_run: bool = False
) -> bool:
return ratelimit.consume(
request, action="confirm_email", key=email.lower(), dry_run=dry_run
)
def handle_verification_email_rate_limit(request, email: str) -> bool:
"""
For email verification by link, it is not an issue if the user runs into rate
limits. The reason is that the link is session independent. Therefore, if the
user hits rate limits, we can just silently skip sending additional
verification emails, as the previous emails that were already sent still
contain valid links. This is different from email verification by code. Here,
the session contains a specific code, meaning, silently skipping new
verification emails is not an option, and we must hard fail (429) instead. The
latter was missing, fixed.
"""
rl_ok = consume_email_verification_rate_limit(request, email)
if not rl_ok and app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED:
raise ImmediateHttpResponse(ratelimit.respond_429(request))
return rl_ok
def send_verification_email(request, user, signup=False, email=None) -> bool:
"""
Email verification mails are sent:
a) Explicitly: when a user signs up
b) Implicitly: when a user attempts to log in using an unverified
email while EMAIL_VERIFICATION is mandatory.
Especially in case of b), we want to limit the number of mails
sent (consider a user retrying a few times), which is why there is
a cooldown period before sending a new mail. This cooldown period
can be configured in ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN setting.
TODO: This code is doing way too much. Looking up EmailAddress, creating
if not present, etc. To be refactored.
"""
from allauth.account.utils import user_email
adapter = get_adapter()
sent = False
email_address = None
if not email:
email = user_email(user)
if not email:
email_address = (
EmailAddress.objects.filter(user=user).order_by("verified", "pk").first()
)
if email_address:
email = email_address.email
if email:
if email_address is None:
try:
email_address = EmailAddress.objects.get_for_user(user, email)
except EmailAddress.DoesNotExist:
pass
if email_address is not None:
if not email_address.verified:
send_email = handle_verification_email_rate_limit(
request, email_address.email
)
if send_email:
send_email = adapter.should_send_confirmation_mail(
request, email_address, signup
)
if send_email:
email_address.send_confirmation(request, signup=signup)
sent = True
else:
send_email = False
else:
send_email = True
email_address = EmailAddress.objects.add_email(
request, user, email, signup=signup, confirm=True
)
sent = True
assert email_address
# At this point, if we were supposed to send an email we have sent it.
if send_email:
adapter.add_message(
request,
messages.INFO,
"account/messages/email_confirmation_sent.txt",
{"email": email, "login": not signup, "signup": signup},
)
return sent
def is_verification_rate_limited(request: HttpRequest, login: Login) -> bool:
"""
Returns whether or not the email verification is *hard* rate limited.
Hard, meaning, it would be blocking login (verification by code, not link).
"""
if (
(not login.email)
or (not app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED)
or login.email_verification != app_settings.EmailVerificationMethod.MANDATORY
):
return False
try:
email_address = EmailAddress.objects.get_for_user(login.user, login.email)
if not email_address.verified:
if not consume_email_verification_rate_limit(
request, login.email, dry_run=True
):
return True
except EmailAddress.DoesNotExist:
pass
return False

View File

@@ -0,0 +1,112 @@
import time
from typing import Any, Dict, Optional, Tuple
from django.contrib.auth import get_user_model
from django.http import HttpRequest
from allauth.account import app_settings
from allauth.account.adapter import get_adapter
from allauth.account.internal.flows.login_by_code import compare_code
from allauth.account.internal.stagekit import clear_login
from allauth.account.models import EmailAddress, EmailConfirmationMixin
from allauth.core import context
EMAIL_VERIFICATION_CODE_SESSION_KEY = "account_email_verification_code"
class EmailVerificationModel(EmailConfirmationMixin):
def __init__(self, email_address: EmailAddress, key: Optional[str] = None):
self.email_address = email_address
if not key:
key = request_email_verification_code(
context.request, user=email_address.user, email=email_address.email
)
self.key = key
@classmethod
def create(cls, email_address: EmailAddress):
return EmailVerificationModel(email_address)
@classmethod
def from_key(cls, key):
verification, _ = get_pending_verification(context.request, peek=True)
if not verification or not compare_code(actual=key, expected=verification.key):
return None
return verification
def key_expired(self):
return False
def clear_state(request):
request.session.pop(EMAIL_VERIFICATION_CODE_SESSION_KEY, None)
clear_login(request)
def request_email_verification_code(
request: HttpRequest,
user,
email: str,
) -> str:
code = ""
pending_verification = {
"at": time.time(),
"failed_attempts": 0,
"email": email,
}
pretend = user is None
if not pretend:
adapter = get_adapter()
code = adapter.generate_email_verification_code()
assert user._meta.pk
pending_verification.update(
{
"user_id": user._meta.pk.value_to_string(user),
"email": email,
"code": code,
}
)
request.session[EMAIL_VERIFICATION_CODE_SESSION_KEY] = pending_verification
return code
def get_pending_verification(
request: HttpRequest, peek: bool = False
) -> Tuple[Optional[EmailVerificationModel], Optional[Dict[str, Any]]]:
if peek:
data = request.session.get(EMAIL_VERIFICATION_CODE_SESSION_KEY)
else:
data = request.session.pop(EMAIL_VERIFICATION_CODE_SESSION_KEY, None)
if not data:
clear_state(request)
return None, None
if time.time() - data["at"] >= app_settings.EMAIL_VERIFICATION_BY_CODE_TIMEOUT:
clear_state(request)
return None, None
if user_id_str := data.get("user_id"):
user_id = get_user_model()._meta.pk.to_python(user_id_str) # type: ignore[union-attr]
user = get_user_model().objects.get(pk=user_id)
email = data["email"]
try:
email_address = EmailAddress.objects.get_for_user(user, email)
except EmailAddress.DoesNotExist:
email_address = EmailAddress(user=user, email=email)
verification = EmailVerificationModel(email_address, key=data["code"])
else:
verification = None
return verification, data
def record_invalid_attempt(
request: HttpRequest, pending_verification: Dict[str, Any]
) -> bool:
n = pending_verification["failed_attempts"]
n += 1
pending_verification["failed_attempts"] = n
if n >= app_settings.EMAIL_VERIFICATION_BY_CODE_MAX_ATTEMPTS:
clear_state(request)
return False
else:
request.session[EMAIL_VERIFICATION_CODE_SESSION_KEY] = pending_verification
return True

View File

@@ -0,0 +1,117 @@
import time
from typing import Any, Dict
from django.http import HttpRequest, HttpResponse
from allauth import app_settings as allauth_settings
from allauth.account.adapter import get_adapter
from allauth.account.models import Login
from allauth.core.exceptions import ImmediateHttpResponse
AUTHENTICATION_METHODS_SESSION_KEY = "account_authentication_methods"
def record_authentication(request, method, **extra_data):
"""Here we keep a log of all authentication methods used within the current
session. Important to note is that having entries here does not imply that
a user is fully signed in. For example, consider a case where a user
authenticates using a password, but fails to complete the 2FA challenge.
Or, a user successfully signs in into an inactive account or one that still
needs verification. In such cases, ``request.user`` is still anonymous, yet,
we do have an entry here.
Example data::
{'method': 'password',
'at': 1701423602.7184925,
'username': 'john.doe'}
{'method': 'socialaccount',
'at': 1701423567.6368647,
'provider': 'amazon',
'uid': 'amzn1.account.K2LI23KL2LK2'}
{'method': 'mfa',
'at': 1701423602.6392953,
'id': 1,
'type': 'totp'}
"""
methods = request.session.get(AUTHENTICATION_METHODS_SESSION_KEY, [])
data = {
"method": method,
"at": time.time(),
**extra_data,
}
methods.append(data)
request.session[AUTHENTICATION_METHODS_SESSION_KEY] = methods
def _get_login_hook_kwargs(login: Login) -> Dict[str, Any]:
"""
TODO: Just break backwards compatibility and pass only `login` to
`pre/post_login()`.
"""
return dict(
email_verification=login.email_verification,
redirect_url=login.redirect_url,
signal_kwargs=login.signal_kwargs,
signup=login.signup,
email=login.email,
)
def perform_password_login(
request: HttpRequest, credentials: Dict[str, Any], login: Login
) -> HttpResponse:
extra_data = {
field: credentials.get(field)
for field in ["email", "username"]
if credentials.get(field)
}
record_authentication(request, method="password", **extra_data)
return perform_login(request, login)
def perform_login(request: HttpRequest, login: Login) -> HttpResponse:
adapter = get_adapter()
hook_kwargs = _get_login_hook_kwargs(login)
response = adapter.pre_login(request, login.user, **hook_kwargs)
if response:
return response
return resume_login(request, login)
def resume_login(request: HttpRequest, login: Login) -> HttpResponse:
from allauth.account.stages import LoginStageController
adapter = get_adapter()
ctrl = LoginStageController(request, login)
try:
response = ctrl.handle()
if response:
return response
adapter.login(request, login.user)
hook_kwargs = _get_login_hook_kwargs(login)
response = adapter.post_login(request, login.user, **hook_kwargs)
if response:
return response
except ImmediateHttpResponse as e:
if allauth_settings.HEADLESS_ENABLED:
from allauth.headless.internal.restkit.response import APIResponse
if isinstance(e.response, APIResponse):
raise e
response = e.response
return response
def is_login_rate_limited(request, login: Login) -> bool:
from allauth.account.internal.flows.email_verification import (
is_verification_rate_limited,
)
if is_verification_rate_limited(request, login):
return True
return False

View File

@@ -0,0 +1,133 @@
import time
from typing import Any, Dict, Optional, Tuple
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractBaseUser
from django.http import HttpRequest
from allauth.account import app_settings
from allauth.account.adapter import get_adapter
from allauth.account.internal.flows.email_verification import (
verify_email_indirectly,
)
from allauth.account.internal.flows.login import (
perform_login,
record_authentication,
)
from allauth.account.internal.flows.signup import send_unknown_account_mail
from allauth.account.internal.stagekit import clear_login, stash_login
from allauth.account.models import Login
LOGIN_CODE_STATE_KEY = "login_code"
def request_login_code(
request: HttpRequest, email: str, login: Optional[Login] = None
) -> None:
from allauth.account.utils import filter_users_by_email
initiated_by_user = login is None
adapter = get_adapter()
users = filter_users_by_email(email, is_active=True, prefer_verified=True)
pending_login = {
"at": time.time(),
"email": email,
"failed_attempts": 0,
"initiated_by_user": initiated_by_user,
}
if not users:
user = None
send_unknown_account_mail(request, email)
else:
user = users[0]
code = adapter.generate_login_code()
context = {
"request": request,
"code": code,
}
adapter.send_mail("account/email/login_code", email, context)
pending_login.update(
{"code": code, "user_id": user._meta.pk.value_to_string(user)}
)
adapter.add_message(
request,
messages.SUCCESS,
"account/messages/login_code_sent.txt",
{"email": email},
)
if initiated_by_user:
login = Login(user=user, email=email)
login.state["stages"] = {"current": "login_by_code"}
assert login
login.state[LOGIN_CODE_STATE_KEY] = pending_login
if initiated_by_user:
stash_login(request, login)
def get_pending_login(
request, login: Login, peek: bool = False
) -> Tuple[Optional[AbstractBaseUser], Optional[Dict[str, Any]]]:
if peek:
data = login.state.get(LOGIN_CODE_STATE_KEY)
else:
data = login.state.pop(LOGIN_CODE_STATE_KEY, None)
if not data:
return None, None
if time.time() - data["at"] >= app_settings.LOGIN_BY_CODE_TIMEOUT:
login.state.pop(LOGIN_CODE_STATE_KEY, None)
clear_login(request)
return None, None
user_id_str = data.get("user_id")
user = None
if user_id_str:
user_id = get_user_model()._meta.pk.to_python(user_id_str) # type: ignore[union-attr]
user = get_user_model().objects.get(pk=user_id)
return user, data
def record_invalid_attempt(request, login: Login) -> bool:
from allauth.account.internal.stagekit import stash_login, unstash_login
pending_login = login.state[LOGIN_CODE_STATE_KEY]
n = pending_login["failed_attempts"]
n += 1
pending_login["failed_attempts"] = n
if n >= app_settings.LOGIN_BY_CODE_MAX_ATTEMPTS:
unstash_login(request)
return False
else:
login.state[LOGIN_CODE_STATE_KEY] = pending_login
stash_login(request, login)
return True
def perform_login_by_code(
request: HttpRequest,
stage,
redirect_url: Optional[str],
):
state = stage.login.state[LOGIN_CODE_STATE_KEY]
email = state["email"]
user = stage.login.user
record_authentication(request, method="code", email=email)
verify_email_indirectly(request, user, email)
if state["initiated_by_user"]:
# Just requesting a login code does is not considered to be a real login,
# yet, is needed in order to make the stage machinery work. Now that we've
# completed the code, let's start a real login.
login = Login(
user=user,
redirect_url=redirect_url,
email=email,
)
return perform_login(request, login)
else:
return stage.exit()
def compare_code(*, actual, expected) -> bool:
actual = actual.replace(" ", "").lower()
expected = expected.replace(" ", "").lower()
return expected and actual == expected

View File

@@ -0,0 +1,16 @@
from django.contrib import messages
from django.http import HttpRequest
from allauth.account.internal.stagekit import clear_login
def logout(request: HttpRequest) -> None:
from allauth.account.adapter import get_adapter
if request.user.is_authenticated:
adapter = get_adapter()
adapter.add_message(
request, messages.SUCCESS, "account/messages/logged_out.txt"
)
adapter.logout(request)
clear_login(request)

View File

@@ -0,0 +1,173 @@
from typing import Optional
from django.contrib import messages
from django.http import HttpRequest
from allauth.account import app_settings, signals
from allauth.account.adapter import get_adapter
from allauth.account.internal.flows.reauthentication import (
raise_if_reauthentication_required,
)
from allauth.account.models import EmailAddress
def can_delete_email(email_address: EmailAddress) -> bool:
adapter = get_adapter()
return adapter.can_delete_email(email_address)
def delete_email(request: HttpRequest, email_address: EmailAddress) -> bool:
if app_settings.REAUTHENTICATION_REQUIRED:
raise_if_reauthentication_required(request)
success = False
adapter = get_adapter()
if not can_delete_email(email_address):
adapter.add_message(
request,
messages.ERROR,
"account/messages/cannot_delete_primary_email.txt",
{"email": email_address.email},
)
else:
email_address.remove()
signals.email_removed.send(
sender=EmailAddress,
request=request,
user=request.user,
email_address=email_address,
)
adapter.add_message(
request,
messages.SUCCESS,
"account/messages/email_deleted.txt",
{"email": email_address.email},
)
adapter.send_notification_mail(
"account/email/email_deleted",
request.user,
{"deleted_email": email_address.email},
)
success = True
return success
def add_email(request: HttpRequest, form):
if app_settings.REAUTHENTICATION_REQUIRED:
raise_if_reauthentication_required(request)
email_address = form.save(request)
adapter = get_adapter(request)
adapter.add_message(
request,
messages.INFO,
"account/messages/email_confirmation_sent.txt",
{"email": form.cleaned_data["email"]},
)
if email_address.pk:
signals.email_added.send(
sender=EmailAddress,
request=request,
user=request.user,
email_address=email_address,
)
def can_mark_as_primary(email_address: EmailAddress):
return (
email_address.verified
or not EmailAddress.objects.filter(
user=email_address.user, verified=True
).exists()
)
def mark_as_primary(request: HttpRequest, email_address: EmailAddress):
if app_settings.REAUTHENTICATION_REQUIRED:
raise_if_reauthentication_required(request)
# Not primary=True -- Slightly different variation, don't
# require verified unless moving from a verified
# address. Ignore constraint if previous primary email
# address is not verified.
success = False
if not can_mark_as_primary(email_address):
get_adapter().add_message(
request,
messages.ERROR,
"account/messages/unverified_primary_email.txt",
)
else:
assert request.user.is_authenticated
from_email_address = EmailAddress.objects.filter(
user=request.user, primary=True
).first()
email_address.set_as_primary()
adapter = get_adapter()
adapter.add_message(
request,
messages.SUCCESS,
"account/messages/primary_email_set.txt",
)
emit_email_changed(request, from_email_address, email_address)
success = True
return success
def emit_email_changed(request, from_email_address, to_email_address) -> None:
user = to_email_address.user
signals.email_changed.send(
sender=EmailAddress,
request=request,
user=user,
from_email_address=from_email_address,
to_email_address=to_email_address,
)
if from_email_address:
get_adapter().send_notification_mail(
"account/email/email_changed",
user,
context={
"from_email": from_email_address.email,
"to_email": to_email_address.email,
},
email=from_email_address.email,
)
def assess_unique_email(email) -> Optional[bool]:
"""
True -- email is unique
False -- email is already in use
None -- email is in use, but we should hide that using email verification.
"""
from allauth.account.utils import filter_users_by_email
if not filter_users_by_email(email):
# All good.
return True
elif not app_settings.PREVENT_ENUMERATION:
# Fail right away.
return False
elif (
app_settings.EMAIL_VERIFICATION
== app_settings.EmailVerificationMethod.MANDATORY
):
# In case of mandatory verification and enumeration prevention,
# we can avoid creating a new account with the same (unverified)
# email address, because we are going to send an email anyway.
assert app_settings.PREVENT_ENUMERATION
return None
elif app_settings.PREVENT_ENUMERATION == "strict":
# We're going to be strict on enumeration prevention, and allow for
# this email address to pass even though it already exists. In this
# scenario, you can signup multiple times using the same email
# address resulting in multiple accounts with an unverified email.
return True
else:
assert app_settings.PREVENT_ENUMERATION is True
# Conflict. We're supposed to prevent enumeration, but we can't
# because that means letting the user in, while emails are required
# to be unique. In this case, uniqueness takes precedence over
# enumeration prevention.
return False

View File

@@ -0,0 +1,55 @@
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import AbstractBaseUser
from django.http import HttpRequest
from allauth.account import app_settings, signals
from allauth.account.adapter import get_adapter
from allauth.account.internal.flows.logout import logout
def change_password(user: AbstractBaseUser, password: str) -> None:
get_adapter().set_password(user, password)
def finalize_password_change(request: HttpRequest, user: AbstractBaseUser) -> bool:
logged_out = logout_on_password_change(request, user)
adapter = get_adapter(request)
adapter.add_message(
request,
messages.SUCCESS,
"account/messages/password_changed.txt",
)
adapter.send_notification_mail("account/email/password_changed", user)
signals.password_changed.send(
sender=user.__class__,
request=request,
user=user,
)
return logged_out
def finalize_password_set(request: HttpRequest, user: AbstractBaseUser) -> bool:
logged_out = logout_on_password_change(request, user)
adapter = get_adapter(request)
adapter.add_message(request, messages.SUCCESS, "account/messages/password_set.txt")
adapter.send_notification_mail("account/email/password_set", user)
signals.password_set.send(
sender=user.__class__,
request=request,
user=user,
)
return logged_out
def logout_on_password_change(request: HttpRequest, user: AbstractBaseUser) -> bool:
# Since it is the default behavior of Django to invalidate all sessions on
# password change, this function actually has to preserve the session when
# logout isn't desired.
logged_out = True
if not app_settings.LOGOUT_ON_PASSWORD_CHANGE:
update_session_auth_hash(request, user)
logged_out = False
else:
logout(request)
return logged_out

View File

@@ -0,0 +1,62 @@
from urllib.parse import quote
from django.contrib import messages
from django.contrib.auth.models import AbstractBaseUser
from django.http import HttpRequest
from django.urls import reverse
from allauth.account import signals
from allauth.account.adapter import get_adapter
from allauth.account.models import EmailAddress
from allauth.core.internal.httpkit import get_frontend_url
from allauth.utils import build_absolute_uri
def reset_password(user: AbstractBaseUser, password: str) -> None:
get_adapter().set_password(user, password)
def finalize_password_reset(request: HttpRequest, user: AbstractBaseUser) -> None:
adapter = get_adapter()
if user:
# User successfully reset the password, clear any
# possible cache entries for all email addresses.
for email in EmailAddress.objects.filter(user_id=user.pk):
adapter._delete_login_attempts_cached_email(request, email=email.email)
adapter.add_message(
request,
messages.SUCCESS,
"account/messages/password_changed.txt",
)
signals.password_reset.send(
sender=user.__class__,
request=request,
user=user,
)
adapter.send_notification_mail("account/email/password_reset", user)
def get_reset_password_url(request: HttpRequest) -> str:
url = get_frontend_url(request, "account_reset_password")
if not url:
url = build_absolute_uri(request, reverse("account_reset_password"))
return url
def get_reset_password_from_key_url(request: HttpRequest, key: str) -> str:
"""
Method intented to be overriden in case the password reset email
needs to point to your frontend/SPA.
"""
url = get_frontend_url(request, "account_reset_password_from_key", key=key)
if not url:
# We intentionally accept an opaque `key` on the interface here, and not
# implementation details such as a separate `uidb36` and `key. Ideally,
# this should have done on `urls` level as well.
path = reverse(
"account_reset_password_from_key", kwargs={"uidb36": "UID", "key": "KEY"}
)
path = path.replace("UID-KEY", quote(key))
url = build_absolute_uri(request, path)
return url

View File

@@ -0,0 +1,108 @@
import time
from typing import Dict, List, Optional
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.http import HttpRequest, HttpResponseRedirect
from django.urls import resolve, reverse
from django.utils.http import urlencode
from allauth import app_settings as allauth_settings
from allauth.account import app_settings
from allauth.account.authentication import get_authentication_records
from allauth.account.internal.flows.login import record_authentication
from allauth.core.exceptions import ReauthenticationRequired
from allauth.core.internal.httpkit import (
deserialize_request,
serialize_request,
)
from allauth.utils import import_callable
STATE_SESSION_KEY = "account_reauthentication_state"
def reauthenticate_by_password(request: HttpRequest) -> None:
record_authentication(request, method="password", reauthenticated=True)
def stash_and_reauthenticate(
request: HttpRequest, state: dict, callback: str
) -> HttpResponseRedirect:
request.session[STATE_SESSION_KEY] = {
"state": state,
"callback": callback,
}
return HttpResponseRedirect(reverse("account_reauthenticate"))
def suspend_request(request: HttpRequest, redirect_to: str) -> HttpResponseRedirect:
path = request.get_full_path()
if request.method == "POST":
request.session[STATE_SESSION_KEY] = {"request": serialize_request(request)}
return HttpResponseRedirect(
redirect_to + "?" + urlencode({REDIRECT_FIELD_NAME: path})
)
def resume_request(request: HttpRequest) -> Optional[HttpResponseRedirect]:
from allauth.account.utils import get_next_redirect_url
state = request.session.pop(STATE_SESSION_KEY, None)
if state and "callback" in state:
callback = import_callable(state["callback"])
return callback(request, state["state"])
url = get_next_redirect_url(request, REDIRECT_FIELD_NAME)
if not url:
return None
if state and "request" in state:
suspended_request = deserialize_request(state["request"], request)
if suspended_request.path == url:
resolved = resolve(suspended_request.path)
return resolved.func(suspended_request, *resolved.args, **resolved.kwargs)
return HttpResponseRedirect(url)
def raise_if_reauthentication_required(request: HttpRequest) -> None:
if not did_recently_authenticate(request):
raise ReauthenticationRequired()
def did_recently_authenticate(request: HttpRequest) -> bool:
if request.user.is_anonymous:
return False
if not get_reauthentication_flows(request.user):
# TODO: This user only has social accounts attached. Now, ideally, you
# would want to reauthenticate over at the social account provider. For
# now, this is not implemented. Although definitely suboptimal, this
# method is currently used for reauthentication checks over at MFA, and,
# users that delegate the security of their account to an external
# provider like Google typically use MFA over there anyway.
return True
methods = get_authentication_records(request)
if not methods:
return False
authenticated_at = methods[-1]["at"]
return time.time() - authenticated_at < app_settings.REAUTHENTICATION_TIMEOUT
def get_reauthentication_flows(user) -> List[Dict]:
ret: List[Dict] = []
if not user.is_authenticated:
return ret
if user.has_usable_password():
entry = {
"id": "reauthenticate",
}
ret.append(entry)
if allauth_settings.MFA_ENABLED:
from allauth.mfa.models import Authenticator
from allauth.mfa.utils import is_mfa_enabled
types = []
for typ in Authenticator.Type:
if is_mfa_enabled(user, types=[typ]):
types.append(typ)
if types:
ret.append({"id": "mfa_reauthenticate", "types": types})
return ret

View File

@@ -0,0 +1,72 @@
from django.contrib import messages
from django.http import HttpRequest, HttpResponse
from django.urls import reverse
from allauth.account import app_settings, signals
from allauth.account.adapter import get_adapter
from allauth.account.internal.flows import email_verification_by_code
from allauth.account.internal.flows.login import perform_login
from allauth.account.models import Login
from allauth.core.internal.httpkit import get_frontend_url
from allauth.utils import build_absolute_uri
def prevent_enumeration(request: HttpRequest, email: str) -> HttpResponse:
adapter = get_adapter(request)
adapter.send_account_already_exists_mail(email)
adapter.add_message(
request,
messages.INFO,
"account/messages/email_confirmation_sent.txt",
{"email": email, "login": False, "signup": True},
)
if app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED:
email_verification_by_code.request_email_verification_code(
request, user=None, email=email
)
resp = adapter.respond_email_verification_sent(request, None)
return resp
def send_unknown_account_mail(request: HttpRequest, email: str) -> None:
if not app_settings.EMAIL_UNKNOWN_ACCOUNTS:
return None
signup_url = get_signup_url(request)
context = {
"request": request,
"signup_url": signup_url,
}
get_adapter().send_mail("account/email/unknown_account", email, context)
def get_signup_url(request: HttpRequest) -> str:
url = get_frontend_url(request, "account_signup")
if not url:
url = build_absolute_uri(request, reverse("account_signup"))
return url
def complete_signup(
request,
*,
user,
email_verification=None,
redirect_url=None,
signal_kwargs=None,
by_passkey=False,
):
if signal_kwargs is None:
signal_kwargs = {}
signals.user_signed_up.send(
sender=user.__class__, request=request, user=user, **signal_kwargs
)
login = Login(
user=user,
email_verification=email_verification,
redirect_url=redirect_url,
signal_kwargs=signal_kwargs,
signup=True,
)
if by_passkey:
login.state["passkey_signup"] = True
return perform_login(request, login)

View File

@@ -0,0 +1,58 @@
import time
from typing import Optional
from django.http import HttpResponseRedirect
from django.urls import reverse
from allauth.account import app_settings
from allauth.account.models import Login
from allauth.account.stages import LoginStage, LoginStageController
LOGIN_SESSION_KEY = "account_login"
def get_pending_stage(request) -> Optional[LoginStage]:
stage = None
if not request.user.is_authenticated:
login = unstash_login(request, peek=True)
if login:
ctrl = LoginStageController(request, login)
stage = ctrl.get_pending_stage()
return stage
def redirect_to_pending_stage(request, stage: LoginStage):
if stage.urlname:
return HttpResponseRedirect(reverse(stage.urlname))
clear_login(request)
return HttpResponseRedirect(reverse("account_login"))
def clear_login(request):
request.session.pop(LOGIN_SESSION_KEY, None)
def unstash_login(request, peek=False):
login = None
if peek:
data = request.session.get(LOGIN_SESSION_KEY)
else:
data = request.session.pop(LOGIN_SESSION_KEY, None)
if isinstance(data, dict):
try:
login = Login.deserialize(data)
except ValueError:
pass
else:
if time.time() - login.initiated_at > app_settings.LOGIN_TIMEOUT:
login = None
clear_login(request)
else:
request._account_login_accessed = True
return login
def stash_login(request, login):
request.session[LOGIN_SESSION_KEY] = login.serialize()
request._account_login_accessed = True

View File

@@ -0,0 +1,46 @@
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from django.db.models import Count
from allauth.account.models import EmailAddress
from allauth.account.utils import user_email
class Command(BaseCommand):
def handle(self, *args, **options):
for user in self.get_users_with_multiple_primary_email():
self.unprimary_extra_primary_emails(user)
def get_users_with_multiple_primary_email(self):
user_pks = []
for email_address_dict in (
EmailAddress.objects.filter(primary=True)
.values("user")
.annotate(Count("user"))
.filter(user__count__gt=1)
):
user_pks.append(email_address_dict["user"])
return get_user_model().objects.filter(pk__in=user_pks)
def unprimary_extra_primary_emails(self, user):
primary_email_addresses = EmailAddress.objects.filter(user=user, primary=True)
for primary_email_address in primary_email_addresses:
if primary_email_address.email == user_email(user):
break
else:
# Didn't find the main email addresses and break the for loop
print(
"WARNING: Multiple primary without a user.email match for"
"user pk %s; (tried: %s, using: %s)"
) % (
user.pk,
", ".join(
[email_address.email for email_address in primary_email_addresses]
),
primary_email_address,
)
primary_email_addresses.exclude(pk=primary_email_address.pk).update(
primary=False
)

View File

@@ -0,0 +1,136 @@
from datetime import timedelta
from typing import Optional
from django.db import models
from django.db.models import Q
from django.utils import timezone
from . import app_settings
class EmailAddressManager(models.Manager):
def can_add_email(self, user):
ret = True
if app_settings.CHANGE_EMAIL:
# We always allow adding an email in this case, regardless of
# `MAX_EMAIL_ADDRESSES`, as adding actually adds a temporary email
# that the user wants to change to.
return True
elif app_settings.MAX_EMAIL_ADDRESSES:
count = self.filter(user=user).count()
ret = count < app_settings.MAX_EMAIL_ADDRESSES
return ret
def get_new(self, user):
"""
Returns the email address the user is in the process of changing to, if any.
"""
assert app_settings.CHANGE_EMAIL
return (
self.model.objects.filter(user=user, verified=False).order_by("pk").last()
)
def add_new_email(self, request, user, email):
"""
Adds an email address the user wishes to change to, replacing his
current email address once confirmed.
"""
assert app_settings.CHANGE_EMAIL
instance = self.get_new(user)
email = email.lower()
if not instance:
instance = self.model.objects.create(user=user, email=email)
else:
# Apparently, the user was already in the process of changing his
# email. Reuse that temporary email address.
instance.email = email
instance.verified = False
instance.primary = False
instance.save()
instance.send_confirmation(request)
return instance
def add_email(self, request, user, email, confirm=False, signup=False):
email = email.lower()
email_address, created = self.get_or_create(
user=user, email=email, defaults={"email": email}
)
if created and confirm:
email_address.send_confirmation(request, signup=signup)
return email_address
def get_verified(self, user):
return self.filter(user=user, verified=True).order_by("-primary", "pk").first()
def get_primary(self, user):
try:
return self.get(user=user, primary=True)
except self.model.DoesNotExist:
return None
def get_primary_email(self, user) -> Optional[str]:
from allauth.account.utils import user_email
primary = self.get_primary(user)
if primary:
email = primary.email
else:
email = user_email(user)
return email
def get_users_for(self, email):
# this is a list rather than a generator because we probably want to
# do a len() on it right away
return [
address.user for address in self.filter(verified=True, email=email.lower())
]
def fill_cache_for_user(self, user, addresses):
"""
In a multi-db setup, inserting records and re-reading them later
on may result in not being able to find newly inserted
records. Therefore, we maintain a cache for the user so that
we can avoid database access when we need to re-read..
"""
user._emailaddress_cache = addresses
def get_for_user(self, user, email):
cache_key = "_emailaddress_cache"
addresses = getattr(user, cache_key, None)
email = email.lower()
if addresses is None:
ret = self.get(user=user, email=email)
# To avoid additional lookups when e.g.
# EmailAddress.set_as_primary() starts touching self.user
ret.user = user
return ret
else:
for address in addresses:
if address.email == email:
return address
raise self.model.DoesNotExist()
def is_verified(self, email):
return self.filter(email=email.lower(), verified=True).exists()
def lookup(self, emails):
return self.filter(email__in=[e.lower() for e in emails])
class EmailConfirmationManager(models.Manager):
def all_expired(self):
return self.filter(self.expired_q())
def all_valid(self):
return self.exclude(self.expired_q()).filter(email_address__verified=False)
def expired_q(self):
sent_threshold = timezone.now() - timedelta(
days=app_settings.EMAIL_CONFIRMATION_EXPIRE_DAYS
)
return Q(sent__lt=sent_threshold)
def delete_expired_confirmations(self):
self.all_expired().delete()

View File

@@ -0,0 +1,93 @@
import os
from types import SimpleNamespace
from django.http import HttpResponseRedirect
from django.urls import NoReverseMatch, reverse
from django.utils.decorators import sync_and_async_middleware
from asgiref.sync import iscoroutinefunction
from allauth.account.adapter import get_adapter
from allauth.account.internal import flows
from allauth.core import context
from allauth.core.exceptions import (
ImmediateHttpResponse,
ReauthenticationRequired,
)
@sync_and_async_middleware
def AccountMiddleware(get_response):
if iscoroutinefunction(get_response):
async def middleware(request):
request.allauth = SimpleNamespace()
with context.request_context(request):
response = await get_response(request)
if _should_redirect_accounts(request, response):
response = await _aredirect_accounts(request)
return response
else:
def middleware(request):
request.allauth = SimpleNamespace()
with context.request_context(request):
response = get_response(request)
if _should_redirect_accounts(request, response):
response = _redirect_accounts(request)
return response
def process_exception(request, exception):
if isinstance(exception, ImmediateHttpResponse):
return exception.response
elif isinstance(exception, ReauthenticationRequired):
redirect_url = reverse("account_login")
methods = get_adapter().get_reauthentication_methods(request.user)
if methods:
redirect_url = methods[0]["url"]
return flows.reauthentication.suspend_request(request, redirect_url)
middleware.process_exception = process_exception
return middleware
def _should_redirect_accounts(request, response) -> bool:
"""
URLs should be hackable. Yet, assuming allauth is included like this...
path("accounts/", include("allauth.urls")),
... and a user would attempt to navigate to /accounts/, a 404 would be
presented. This code catches that 404, and redirects to either the email
management overview or the login page, depending on whether or not the user
is authenticated.
"""
if response.status_code != 404:
return False
try:
login_path = reverse("account_login")
email_path = reverse("account_email")
except NoReverseMatch:
# Project might have deviated URLs, let's keep out of the way.
return False
prefix = os.path.commonprefix([login_path, email_path])
if len(prefix) <= 1 or prefix != request.path:
return False
# If we have a prefix that is not just '/', and that is what our request is
# pointing to, redirect.
return True
async def _aredirect_accounts(request) -> HttpResponseRedirect:
email_path = reverse("account_email")
login_path = reverse("account_login")
user = await request.auser()
return HttpResponseRedirect(email_path if user.is_authenticated else login_path)
def _redirect_accounts(request) -> HttpResponseRedirect:
email_path = reverse("account_email")
login_path = reverse("account_login")
user = request.user
return HttpResponseRedirect(email_path if user.is_authenticated else login_path)

View File

@@ -0,0 +1,105 @@
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
UNIQUE_EMAIL = getattr(settings, "ACCOUNT_UNIQUE_EMAIL", True)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="EmailAddress",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
(
"email",
models.EmailField(
unique=UNIQUE_EMAIL,
max_length=75,
verbose_name="email address",
),
),
(
"verified",
models.BooleanField(default=False, verbose_name="verified"),
),
(
"primary",
models.BooleanField(default=False, verbose_name="primary"),
),
(
"user",
models.ForeignKey(
verbose_name="user",
to=settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
),
),
],
options={
"verbose_name": "email address",
"verbose_name_plural": "email addresses",
},
bases=(models.Model,),
),
migrations.CreateModel(
name="EmailConfirmation",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
(
"created",
models.DateTimeField(
default=django.utils.timezone.now,
verbose_name="created",
),
),
("sent", models.DateTimeField(null=True, verbose_name="sent")),
(
"key",
models.CharField(unique=True, max_length=64, verbose_name="key"),
),
(
"email_address",
models.ForeignKey(
verbose_name="email address",
to="account.EmailAddress",
on_delete=models.CASCADE,
),
),
],
options={
"verbose_name": "email confirmation",
"verbose_name_plural": "email confirmations",
},
bases=(models.Model,),
),
]
if not UNIQUE_EMAIL:
operations += [
migrations.AlterUniqueTogether(
name="emailaddress",
unique_together=set([("user", "email")]),
),
]

View File

@@ -0,0 +1,32 @@
from django.conf import settings
from django.db import migrations, models
UNIQUE_EMAIL = getattr(settings, "ACCOUNT_UNIQUE_EMAIL", True)
EMAIL_MAX_LENGTH = getattr(settings, "ACCOUNT_EMAIL_MAX_LENGTH", 254)
class Migration(migrations.Migration):
dependencies = [
("account", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="emailaddress",
name="email",
field=models.EmailField(
unique=UNIQUE_EMAIL,
max_length=EMAIL_MAX_LENGTH,
verbose_name="email address",
),
),
]
if not UNIQUE_EMAIL:
operations += [
migrations.AlterUniqueTogether(
name="emailaddress",
unique_together=set([("user", "email")]),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 4.2.2 on 2023-06-14 12:52
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("account", "0002_email_max_length"),
]
operations = (
[
migrations.AlterUniqueTogether(
name="emailaddress",
unique_together={("user", "email")},
),
migrations.AddConstraint(
model_name="emailaddress",
constraint=models.UniqueConstraint(
condition=models.Q(("verified", True)),
fields=["email"],
name="unique_verified_email",
),
),
]
if getattr(settings, "ACCOUNT_UNIQUE_EMAIL", True)
else []
)

View File

@@ -0,0 +1,21 @@
from django.conf import settings
from django.db import migrations, models
EMAIL_MAX_LENGTH = getattr(settings, "ACCOUNT_EMAIL_MAX_LENGTH", 254)
class Migration(migrations.Migration):
dependencies = [
("account", "0003_alter_emailaddress_create_unique_verified_email"),
]
operations = [
migrations.AlterField(
model_name="emailaddress",
name="email",
field=models.EmailField(
max_length=EMAIL_MAX_LENGTH, verbose_name="email address"
),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.4 on 2023-08-23 18:17
import django.db.models.functions.text
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("account", "0004_alter_emailaddress_drop_unique_email"),
]
operations = [
migrations.AddIndex(
model_name="emailaddress",
index=models.Index(
django.db.models.functions.text.Upper("email"),
name="account_emailaddress_upper",
),
),
]

View File

@@ -0,0 +1,26 @@
from django.conf import settings
from django.db import migrations
from django.db.models.functions import Lower
from allauth.account import app_settings
def forwards(apps, schema_editor):
EmailAddress = apps.get_model("account.EmailAddress")
User = apps.get_model(settings.AUTH_USER_MODEL)
EmailAddress.objects.all().exclude(email=Lower("email")).update(
email=Lower("email")
)
email_field = app_settings.USER_MODEL_EMAIL_FIELD
if email_field:
User.objects.all().exclude(**{email_field: Lower(email_field)}).update(
**{email_field: Lower(email_field)}
)
class Migration(migrations.Migration):
dependencies = [
("account", "0005_emailaddress_idx_upper_email"),
]
operations = [migrations.RunPython(forwards)]

View File

@@ -0,0 +1,25 @@
from django.conf import settings
from django.db import migrations, models
EMAIL_MAX_LENGTH = getattr(settings, "ACCOUNT_EMAIL_MAX_LENGTH", 254)
class Migration(migrations.Migration):
dependencies = [
("account", "0006_emailaddress_lower"),
]
operations = [
migrations.RemoveIndex(
model_name="emailaddress",
name="account_emailaddress_upper",
),
migrations.AlterField(
model_name="emailaddress",
name="email",
field=models.EmailField(
db_index=True, max_length=EMAIL_MAX_LENGTH, verbose_name="email address"
),
),
]

View File

@@ -0,0 +1,44 @@
from django.conf import settings
from django.db import migrations
from django.db.models import Count
def forwards(apps, schema_editor):
EmailAddress = apps.get_model("account.EmailAddress")
User = apps.get_model(settings.AUTH_USER_MODEL)
user_email_field = getattr(settings, "ACCOUNT_USER_MODEL_EMAIL_FIELD", "email")
def get_users_with_multiple_primary_email():
user_pks = []
for email_address_dict in (
EmailAddress.objects.filter(primary=True)
.values("user")
.annotate(Count("user"))
.filter(user__count__gt=1)
):
user_pks.append(email_address_dict["user"])
return User.objects.filter(pk__in=user_pks)
def unset_extra_primary_emails(user):
qs = EmailAddress.objects.filter(user=user, primary=True)
primary_email_addresses = list(qs)
if not primary_email_addresses:
return
primary_email_address = primary_email_addresses[0]
if user_email_field:
for address in primary_email_addresses:
if address.email.lower() == getattr(user, user_email_field, "").lower():
primary_email_address = address
break
qs.exclude(pk=primary_email_address.pk).update(primary=False)
for user in get_users_with_multiple_primary_email().iterator():
unset_extra_primary_emails(user)
class Migration(migrations.Migration):
dependencies = [
("account", "0007_emailaddress_idx_email"),
]
operations = [migrations.RunPython(forwards)]

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.11 on 2024-05-09 06:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("account", "0008_emailaddress_unique_primary_email_fixup"),
]
operations = [
migrations.AddConstraint(
model_name="emailaddress",
constraint=models.UniqueConstraint(
condition=models.Q(("primary", True)),
fields=("user", "primary"),
name="unique_primary_email",
),
),
]

View File

@@ -0,0 +1,193 @@
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponsePermanentRedirect, HttpResponseRedirect
from django.utils.decorators import method_decorator
from django.utils.html import format_html
from django.views.decorators.cache import never_cache
from allauth.account import app_settings
from allauth.account.adapter import get_adapter
from allauth.account.internal import flows
from allauth.account.internal.decorators import login_not_required
from allauth.account.internal.stagekit import (
get_pending_stage,
redirect_to_pending_stage,
)
from allauth.account.utils import (
get_login_redirect_url,
get_next_redirect_url,
passthrough_next_redirect_url,
)
from allauth.core.exceptions import ImmediateHttpResponse
from allauth.utils import get_request_param
def _ajax_response(request, response, form=None, data=None):
adapter = get_adapter()
if adapter.is_ajax(request):
if isinstance(response, HttpResponseRedirect) or isinstance(
response, HttpResponsePermanentRedirect
):
redirect_to = response["Location"]
else:
redirect_to = None
response = adapter.ajax_response(
request, response, form=form, data=data, redirect_to=redirect_to
)
return response
class RedirectAuthenticatedUserMixin:
@method_decorator(login_not_required)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
if app_settings.AUTHENTICATED_LOGIN_REDIRECTS:
if request.user.is_authenticated:
redirect_to = self.get_authenticated_redirect_url()
response = HttpResponseRedirect(redirect_to)
return _ajax_response(request, response)
else:
stage = get_pending_stage(request)
if stage and stage.is_resumable(request):
return redirect_to_pending_stage(request, stage)
response = super().dispatch(request, *args, **kwargs)
return response
def get_authenticated_redirect_url(self):
redirect_field_name = self.redirect_field_name
return get_login_redirect_url(
self.request,
url=self.get_success_url(),
redirect_field_name=redirect_field_name,
)
class LogoutFunctionalityMixin:
def logout(self):
flows.logout.logout(self.request)
class AjaxCapableProcessFormViewMixin:
def get(self, request, *args, **kwargs):
response = super().get(request, *args, **kwargs)
form = self.get_form()
return _ajax_response(
self.request, response, form=form, data=self._get_ajax_data_if()
)
def post(self, request, *args, **kwargs):
form_class = self.get_form_class()
form = self.get_form(form_class)
if form.is_valid():
response = self.form_valid(form)
else:
response = self.form_invalid(form)
return _ajax_response(
self.request, response, form=form, data=self._get_ajax_data_if()
)
def get_form(self, form_class=None):
form = getattr(self, "_cached_form", None)
if form is None:
form = super().get_form(form_class)
self._cached_form = form
return form
def _get_ajax_data_if(self):
return (
self.get_ajax_data()
if get_adapter(self.request).is_ajax(self.request)
else None
)
def get_ajax_data(self):
return None
class CloseableSignupMixin:
template_name_signup_closed = (
"account/signup_closed." + app_settings.TEMPLATE_EXTENSION
)
def dispatch(self, request, *args, **kwargs):
try:
if not self.is_open():
return self.closed()
except ImmediateHttpResponse as e:
return e.response
return super().dispatch(request, *args, **kwargs)
def is_open(self):
return get_adapter(self.request).is_open_for_signup(self.request)
def closed(self):
response_kwargs = {
"request": self.request,
"template": self.template_name_signup_closed,
}
return self.response_class(**response_kwargs)
class NextRedirectMixin:
redirect_field_name = REDIRECT_FIELD_NAME
def get_context_data(self, **kwargs):
ret = super().get_context_data(**kwargs)
redirect_field_value = get_request_param(self.request, self.redirect_field_name)
ret.update(
{
"redirect_field_name": self.redirect_field_name,
"redirect_field_value": redirect_field_value,
"redirect_field": (
format_html(
'<input type="hidden" name="{}" value="{}">',
self.redirect_field_name,
redirect_field_value,
)
if redirect_field_value
else ""
),
}
)
return ret
def get_success_url(self):
"""
We're in a mixin, so we cannot rely on the fact that our super() has a get_success_url.
Also, we want to check for -- in this order:
1) The `?next=/foo`
2) The `get_succes_url()` if available.
3) The `.success_url` if available.
4) A fallback default success URL: `get_default_success_url()`.
"""
url = self.get_next_url()
if url:
return url
if not url:
if hasattr(super(), "get_success_url"):
try:
url = super().get_success_url()
except ImproperlyConfigured:
# Django's default get_success_url() checks self.succes_url,
# and throws this if that is not set. Yet, in our case, we
# want to fallback to the default.
pass
elif hasattr(self, "success_url"):
url = self.success_url
if url:
url = str(url) # reverse_lazy
if not url:
url = self.get_default_success_url()
return url
def get_default_success_url(self):
return None
def get_next_url(self):
return get_next_redirect_url(self.request, self.redirect_field_name)
def passthrough_next_url(self, url):
return passthrough_next_redirect_url(
self.request, url, self.redirect_field_name
)

View File

@@ -0,0 +1,335 @@
import datetime
import time
from typing import Dict, Optional
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractBaseUser
from django.core import signing
from django.db import models
from django.db.models import Q
from django.db.models.constraints import UniqueConstraint
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from allauth.account import app_settings, signals
from allauth.account.adapter import get_adapter
from allauth.account.managers import (
EmailAddressManager,
EmailConfirmationManager,
)
class EmailAddress(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_("user"),
on_delete=models.CASCADE,
)
email = models.EmailField(
db_index=True,
max_length=app_settings.EMAIL_MAX_LENGTH,
verbose_name=_("email address"),
)
verified = models.BooleanField(verbose_name=_("verified"), default=False)
primary = models.BooleanField(verbose_name=_("primary"), default=False)
objects = EmailAddressManager()
class Meta:
verbose_name = _("email address")
verbose_name_plural = _("email addresses")
unique_together = [("user", "email")]
constraints = [
UniqueConstraint(
fields=["user", "primary"],
name="unique_primary_email",
condition=Q(primary=True),
)
]
if app_settings.UNIQUE_EMAIL:
constraints.append(
UniqueConstraint(
fields=["email"],
name="unique_verified_email",
condition=Q(verified=True),
)
)
def __str__(self):
return self.email
def clean(self):
super().clean()
self.email = self.email.lower()
def can_set_verified(self):
if self.verified:
return True
conflict = False
if app_settings.UNIQUE_EMAIL:
conflict = (
EmailAddress.objects.exclude(pk=self.pk)
.filter(verified=True, email=self.email)
.exists()
)
return not conflict
def set_verified(self, commit=True):
if self.verified:
return True
if self.can_set_verified():
self.verified = True
if commit:
self.save(update_fields=["verified"])
return self.verified
def set_as_primary(self, conditional=False):
"""Marks the email address as primary. In case of `conditional`, it is
only marked as primary if there is no other primary email address set.
"""
from allauth.account.utils import user_email
old_primary = EmailAddress.objects.get_primary(self.user)
if old_primary:
if conditional:
return False
old_primary.primary = False
old_primary.save()
self.primary = True
self.save()
user_email(self.user, self.email, commit=True)
return True
def send_confirmation(self, request=None, signup=False):
model = get_emailconfirmation_model()
confirmation = model.create(self)
confirmation.send(request, signup=signup)
return confirmation
def remove(self):
from allauth.account.utils import user_email
self.delete()
if user_email(self.user) == self.email:
alt = (
EmailAddress.objects.filter(user=self.user)
.order_by("-verified")
.first()
)
alt_email = ""
if alt:
alt_email = alt.email
user_email(self.user, alt_email, commit=True)
class EmailConfirmationMixin:
def confirm(self, request):
email_address = self.email_address
if not email_address.verified:
confirmed = get_adapter().confirm_email(request, email_address)
if confirmed:
return email_address
def send(self, request=None, signup=False):
get_adapter().send_confirmation_mail(request, self, signup)
signals.email_confirmation_sent.send(
sender=self.__class__,
request=request,
confirmation=self,
signup=signup,
)
class EmailConfirmation(EmailConfirmationMixin, models.Model):
email_address = models.ForeignKey(
EmailAddress,
verbose_name=_("email address"),
on_delete=models.CASCADE,
)
created = models.DateTimeField(verbose_name=_("created"), default=timezone.now)
sent = models.DateTimeField(verbose_name=_("sent"), null=True)
key = models.CharField(verbose_name=_("key"), max_length=64, unique=True)
objects = EmailConfirmationManager()
class Meta:
verbose_name = _("email confirmation")
verbose_name_plural = _("email confirmations")
def __str__(self):
return "confirmation for %s" % self.email_address
@classmethod
def create(cls, email_address):
key = get_adapter().generate_emailconfirmation_key(email_address.email)
return cls._default_manager.create(email_address=email_address, key=key)
@classmethod
def from_key(cls, key):
qs = EmailConfirmation.objects.all_valid()
qs = qs.select_related("email_address__user")
emailconfirmation = qs.filter(key=key.lower()).first()
return emailconfirmation
def key_expired(self):
expiration_date = self.sent + datetime.timedelta(
days=app_settings.EMAIL_CONFIRMATION_EXPIRE_DAYS
)
return expiration_date <= timezone.now()
key_expired.boolean = True # type: ignore[attr-defined]
def confirm(self, request):
if not self.key_expired():
return super().confirm(request)
def send(self, request=None, signup=False):
super().send(request=request, signup=signup)
self.sent = timezone.now()
self.save()
class EmailConfirmationHMAC(EmailConfirmationMixin):
def __init__(self, email_address):
self.email_address = email_address
@classmethod
def create(cls, email_address):
return EmailConfirmationHMAC(email_address)
@property
def key(self):
return signing.dumps(obj=self.email_address.pk, salt=app_settings.SALT)
@classmethod
def from_key(cls, key):
try:
max_age = 60 * 60 * 24 * app_settings.EMAIL_CONFIRMATION_EXPIRE_DAYS
pk = signing.loads(key, max_age=max_age, salt=app_settings.SALT)
ret = EmailConfirmationHMAC(EmailAddress.objects.get(pk=pk, verified=False))
except (
signing.SignatureExpired,
signing.BadSignature,
EmailAddress.DoesNotExist,
):
ret = None
return ret
def key_expired(self):
return False
class Login:
"""
Represents a user that is in the process of logging in.
Keyword arguments:
signup -- Indicates whether or not sending the
email is essential (during signup), or if it can be skipped (e.g. in
case email verification is optional and we are only logging in).
"""
# Optional, because we might be prentending logins to prevent user
# enumeration.
user: Optional[AbstractBaseUser]
email_verification: app_settings.EmailVerificationMethod
signal_kwargs: Optional[Dict]
signup: bool
email: Optional[str]
state: Dict
initiated_at: float
redirect_url: Optional[str]
def __init__(
self,
user,
email_verification: Optional[app_settings.EmailVerificationMethod] = None,
redirect_url: Optional[str] = None,
signal_kwargs: Optional[Dict] = None,
signup: bool = False,
email: Optional[str] = None,
state: Optional[Dict] = None,
initiated_at: Optional[float] = None,
):
self.user = user
if not email_verification:
email_verification = app_settings.EMAIL_VERIFICATION
self.email_verification = email_verification
self.redirect_url = redirect_url
self.signal_kwargs = signal_kwargs
self.signup = signup
self.email = email
self.state = {} if state is None else state
self.initiated_at = initiated_at if initiated_at else time.time()
def serialize(self):
from allauth.account.utils import user_pk_to_url_str
# :-( Knowledge of the `socialaccount` is entering the `account` app.
signal_kwargs = self.signal_kwargs
if signal_kwargs is not None:
sociallogin = signal_kwargs.get("sociallogin")
if sociallogin is not None:
signal_kwargs = signal_kwargs.copy()
signal_kwargs["sociallogin"] = sociallogin.serialize()
data = {
"user_pk": user_pk_to_url_str(self.user) if self.user else None,
"email_verification": self.email_verification,
"signup": self.signup,
"redirect_url": self.redirect_url,
"email": self.email,
"signal_kwargs": signal_kwargs,
"state": self.state,
"initiated_at": self.initiated_at,
}
return data
@classmethod
def deserialize(cls, data):
from allauth.account.utils import url_str_to_user_pk
user = None
user_pk = data["user_pk"]
if user_pk is not None:
user = (
get_user_model().objects.filter(pk=url_str_to_user_pk(user_pk)).first()
)
try:
# :-( Knowledge of the `socialaccount` is entering the `account` app.
signal_kwargs = data["signal_kwargs"]
if signal_kwargs is not None:
sociallogin = signal_kwargs.get("sociallogin")
if sociallogin is not None:
from allauth.socialaccount.models import SocialLogin
signal_kwargs = signal_kwargs.copy()
signal_kwargs["sociallogin"] = SocialLogin.deserialize(sociallogin)
return Login(
user=user,
email_verification=data["email_verification"],
redirect_url=data["redirect_url"],
signup=data["signup"],
signal_kwargs=signal_kwargs,
state=data["state"],
initiated_at=data["initiated_at"],
)
except KeyError:
raise ValueError()
def get_emailconfirmation_model():
if app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED:
from allauth.account.internal.flows.email_verification_by_code import (
EmailVerificationModel,
)
return EmailVerificationModel
elif app_settings.EMAIL_CONFIRMATION_HMAC:
model = EmailConfirmationHMAC
else:
model = EmailConfirmation
return model

View File

@@ -0,0 +1,14 @@
import warnings
from allauth.account.internal.flows.reauthentication import (
did_recently_authenticate,
raise_if_reauthentication_required,
)
__all__ = [
"raise_if_reauthentication_required",
"did_recently_authenticate",
]
warnings.warn("allauth.account.reauthentication is deprecated")

View File

@@ -0,0 +1,33 @@
from django.contrib.auth.signals import user_logged_out # noqa
from django.dispatch import Signal
# Provides the arguments "request", "user"
user_logged_in = Signal()
# Typically followed by `user_logged_in` (unless, email verification kicks in)
# Provides the arguments "request", "user"
user_signed_up = Signal()
# Provides the arguments "request", "user"
password_set = Signal()
# Provides the arguments "request", "user"
password_changed = Signal()
# Provides the arguments "request", "user"
password_reset = Signal()
# Provides the arguments "request", "email_address"
email_confirmed = Signal()
# Provides the arguments "request", "confirmation", "signup"
email_confirmation_sent = Signal()
# Provides the arguments "request", "user", "from_email_address",
# "to_email_address"
email_changed = Signal()
# Provides the arguments "request", "user", "email_address"
email_added = Signal()
# Provides the arguments "request", "user", "email_address"
email_removed = Signal()
# Internal/private signal.
_add_email = Signal()

View File

@@ -0,0 +1,180 @@
from typing import Optional
from django.http import HttpResponseRedirect
from django.urls import reverse
from allauth.account import app_settings
from allauth.account.adapter import get_adapter
from allauth.account.app_settings import EmailVerificationMethod
from allauth.account.models import EmailAddress
from allauth.core.internal.httpkit import headed_redirect_response
from allauth.utils import import_callable
class LoginStage:
key: str # Set in subclasses
urlname: Optional[str] = None
def __init__(self, controller, request, login):
if not self.key:
raise ValueError()
self.controller = controller
self.request = request
self.login = login
self.state = (
self.login.state.setdefault("stages", {})
.setdefault(self.key, {})
.setdefault("data", {})
)
def handle(self):
return None, True
def exit(self):
from allauth.account.internal.flows.login import resume_login
self.controller.set_handled(self.key)
return resume_login(self.request, self.login)
def abort(self):
from allauth.account.internal.stagekit import clear_login
clear_login(self.request)
return HttpResponseRedirect(reverse("account_login"))
def is_resumable(self, request):
return True
class LoginStageController:
def __init__(self, request, login):
self.request = request
self.login = login
self.state = self.login.state.setdefault("stages", {})
@classmethod
def enter(cls, request, stage_key):
from allauth.account.internal.stagekit import unstash_login
login = unstash_login(request, peek=True)
if not login:
return None
ctrl = LoginStageController(request, login)
if ctrl.state.get("current") != stage_key:
return None
stages = ctrl.get_stages()
for stage in stages:
if stage.key == stage_key:
return stage
return None
def set_current(self, stage_key):
self.state["current"] = stage_key
def is_handled(self, stage_key):
return self.state.get(stage_key, {}).get("handled", False)
def set_handled(self, stage_key):
stage_state = self.state.setdefault(stage_key, {})
stage_state["handled"] = True
def get_pending_stage(self) -> Optional[LoginStage]:
ret = None
stages = self.get_stages()
for stage in stages:
if self.is_handled(stage.key):
continue
ret = stage
break
return ret
def get_stages(self):
stages = []
adapter = get_adapter(self.request)
paths = adapter.get_login_stages()
for path in paths:
cls = import_callable(path)
stage = cls(self, self.request, self.login)
stages.append(stage)
return stages
def handle(self):
from allauth.account.internal.stagekit import clear_login, stash_login
stages = self.get_stages()
for stage in stages:
if self.is_handled(stage.key):
continue
self.set_current(stage.key)
response, cont = stage.handle()
if response:
if cont:
stash_login(self.request, self.login)
else:
clear_login(self.request)
return response
else:
assert cont
self.set_handled(stage.key)
clear_login(self.request)
class EmailVerificationStage(LoginStage):
key = "verify_email"
urlname = "account_email_verification_sent"
def is_resumable(self, request):
return app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED
def handle(self):
from allauth.account.utils import (
has_verified_email,
send_email_confirmation,
)
response, cont = None, True
login = self.login
email_verification = login.email_verification
if email_verification == EmailVerificationMethod.NONE:
pass
elif email_verification == EmailVerificationMethod.OPTIONAL:
# In case of OPTIONAL verification: send on signup.
if not has_verified_email(login.user, login.email) and login.signup:
send_email_confirmation(
self.request, login.user, signup=login.signup, email=login.email
)
elif email_verification == EmailVerificationMethod.MANDATORY:
if not has_verified_email(login.user, login.email):
send_email_confirmation(
self.request, login.user, signup=login.signup, email=login.email
)
response = get_adapter().respond_email_verification_sent(
self.request, login.user
)
return response, cont
class LoginByCodeStage(LoginStage):
key = "login_by_code"
urlname = "account_confirm_login_code"
def handle(self):
from allauth.account.internal.flows import login_by_code
user, data = login_by_code.get_pending_login(
self.request, self.login, peek=True
)
login_by_code_required = get_adapter().is_login_by_code_required(self.login)
if data is None and not login_by_code_required:
# No pending login, just continue.
return None, True
elif data is None and login_by_code_required:
email = EmailAddress.objects.get_primary_email(self.login.user)
if not email:
# No way of contacting the user.. cannot meet the
# requirements. Abort.
return headed_redirect_response("account_login"), False
login_by_code.request_login_code(self.request, email, login=self.login)
response = headed_redirect_response("account_confirm_login_code")
return response, True

View File

@@ -0,0 +1,24 @@
from django import template
from allauth.account.utils import user_display
register = template.Library()
@register.simple_tag(name="user_display")
def user_display_tag(user):
"""
Example usage::
{% user_display user %}
or if you need to use in a {% blocktrans %}::
{% user_display user as user_display %}
{% blocktrans %}
{{ user_display }} has sent you a gift.
{% endblocktrans %}
"""
return user_display(user)

View File

@@ -0,0 +1,22 @@
from django.http import HttpResponseRedirect
from django.urls import reverse
from allauth.account.adapter import DefaultAccountAdapter
from allauth.core.exceptions import ImmediateHttpResponse
class PreLoginRedirectAccountAdapter(DefaultAccountAdapter):
def pre_login(self, *args, **kwargs):
raise ImmediateHttpResponse(HttpResponseRedirect("/foo"))
def test_adapter_pre_login(settings, user, user_password, client):
settings.ACCOUNT_ADAPTER = (
"allauth.account.tests.test_adapter.PreLoginRedirectAccountAdapter"
)
resp = client.post(
reverse("account_login"),
{"login": user.username, "password": user_password},
)
assert resp.status_code == 302
assert resp["location"] == "/foo"

View File

@@ -0,0 +1,74 @@
import json
from django.conf import settings
from django.urls import reverse
import pytest
from pytest_django.asserts import assertRedirects
from allauth.account import app_settings
@pytest.mark.parametrize(
"headers,ajax_expected",
[
({}, False),
({"HTTP_X_REQUESTED_WITH": "XMLHttpRequest"}, True),
({"HTTP_ACCEPT": "application/json"}, True),
],
)
def test_ajax_headers(db, client, headers, ajax_expected):
resp = client.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": "john@example.org",
"email2": "john@example.org",
"password1": "johndoe",
"password2": "johndoe",
},
**headers,
)
if ajax_expected:
assert resp.status_code == 200
assert resp.json()["location"] == settings.LOGIN_REDIRECT_URL
assert resp.json()["location"] == settings.LOGIN_REDIRECT_URL
else:
assert resp.status_code == 302
assertRedirects(
resp, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False
)
def test_ajax_password_reset(client, user, mailoutbox):
resp = client.post(
reverse("account_reset_password"),
data={"email": user.email},
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
assert len(mailoutbox) == 1
assert mailoutbox[0].to == [user.email]
assert resp["content-type"] == "application/json"
def test_ajax_login_fail(client, db):
resp = client.post(
reverse("account_login"),
{},
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
assert resp.status_code == 400
json.loads(resp.content.decode("utf8"))
# TODO: Actually test something
def test_ajax_login_success(settings, user, user_password, client):
settings.ACCOUNT_EMAIL_VERIFICATION = app_settings.EmailVerificationMethod.OPTIONAL
resp = client.post(
reverse("account_login"),
{"login": user.username, "password": user_password},
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
assert resp.status_code == 200
data = json.loads(resp.content.decode("utf8"))
assert data["location"] == "/accounts/profile/"

View File

@@ -0,0 +1,73 @@
from django.contrib.auth import get_user_model
from django.test.utils import override_settings
from allauth.account import app_settings
from allauth.account.auth_backends import AuthenticationBackend
from allauth.tests import TestCase
class AuthenticationBackendTests(TestCase):
def setUp(self):
user = get_user_model().objects.create(
is_active=True, email="john@example.com", username="john"
)
user.set_password(user.username)
user.save()
self.user = user
@override_settings(
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.USERNAME
) # noqa
def test_auth_by_username(self):
user = self.user
backend = AuthenticationBackend()
self.assertEqual(
backend.authenticate(
request=None, username=user.username, password=user.username
).pk,
user.pk,
)
self.assertEqual(
backend.authenticate(
request=None, username=user.email, password=user.username
),
None,
)
@override_settings(
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.EMAIL
) # noqa
def test_auth_by_email(self):
user = self.user
backend = AuthenticationBackend()
self.assertEqual(
backend.authenticate(
request=None, username=user.email, password=user.username
).pk,
user.pk,
)
self.assertEqual(
backend.authenticate(
request=None, username=user.username, password=user.username
),
None,
)
@override_settings(
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.USERNAME_EMAIL
) # noqa
def test_auth_by_username_or_email(self):
user = self.user
backend = AuthenticationBackend()
self.assertEqual(
backend.authenticate(
request=None, username=user.email, password=user.username
).pk,
user.pk,
)
self.assertEqual(
backend.authenticate(
request=None, username=user.username, password=user.username
).pk,
user.pk,
)

View File

@@ -0,0 +1,442 @@
import json
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.urls import reverse
import pytest
from pytest_django.asserts import assertTemplateNotUsed, assertTemplateUsed
from allauth.account.app_settings import AuthenticationMethod
from allauth.account.models import EmailAddress, EmailConfirmationHMAC
from allauth.account.utils import user_email
def test_ajax_get(auth_client, user):
primary = EmailAddress.objects.filter(user=user).first()
secondary = EmailAddress.objects.create(
email="secondary@email.org", user=user, verified=False, primary=False
)
resp = auth_client.get(
reverse("account_email"), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
data = json.loads(resp.content.decode("utf8"))
assert data["data"] == [
{
"id": primary.pk,
"email": primary.email,
"primary": True,
"verified": True,
},
{
"id": secondary.pk,
"email": secondary.email,
"primary": False,
"verified": False,
},
]
def test_ajax_add(auth_client):
resp = auth_client.post(
reverse("account_email"),
{"action_add": "", "email": "john3@example.org"},
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
data = json.loads(resp.content.decode("utf8"))
assert data["location"] == reverse("account_email")
def test_ajax_add_invalid(auth_client):
resp = auth_client.post(
reverse("account_email"),
{"action_add": "", "email": "john3#example.org"},
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
data = json.loads(resp.content.decode("utf8"))
assert "valid" in data["form"]["fields"]["email"]["errors"][0]
def test_ajax_remove_primary(auth_client, user, settings):
settings.ACCOUNT_AUTHENTICATION_METHOD = "email"
resp = auth_client.post(
reverse("account_email"),
{"action_remove": "", "email": user.email},
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
assertTemplateUsed(resp, "account/messages/cannot_delete_primary_email.txt")
data = json.loads(resp.content.decode("utf8"))
assert data["location"] == reverse("account_email")
def test_remove_secondary(auth_client, user, settings, mailoutbox):
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
secondary = EmailAddress.objects.create(
email="secondary@email.org", user=user, verified=False, primary=False
)
resp = auth_client.post(
reverse("account_email"),
{"action_remove": "", "email": secondary.email},
)
assert not EmailAddress.objects.filter(pk=secondary.pk).exists()
assertTemplateUsed(resp, "account/messages/email_deleted.txt")
assert len(mailoutbox) == 1
assert f"{secondary.email} has been removed" in mailoutbox[0].body
def test_set_primary_unverified(auth_client, user):
secondary = EmailAddress.objects.create(
email="secondary@email.org", user=user, verified=False, primary=False
)
resp = auth_client.post(
reverse("account_email"),
{"action_primary": "", "email": secondary.email},
)
primary = EmailAddress.objects.get(email=user.email)
secondary.refresh_from_db()
assert not secondary.primary
assert primary.primary
assertTemplateUsed(resp, "account/messages/unverified_primary_email.txt")
def test_set_primary(auth_client, user):
primary = EmailAddress.objects.get(email=user.email)
secondary = EmailAddress.objects.create(
email="secondary@email.org", user=user, verified=True, primary=False
)
resp = auth_client.post(
reverse("account_email"),
{"action_primary": "", "email": secondary.email},
)
primary.refresh_from_db()
secondary.refresh_from_db()
assert not primary.primary
assert secondary.primary
assertTemplateUsed(resp, "account/messages/primary_email_set.txt")
def test_verify(auth_client, user):
secondary = EmailAddress.objects.create(
email="secondary@email.org", user=user, verified=False, primary=False
)
resp = auth_client.post(
reverse("account_email"),
{"action_send": "", "email": secondary.email},
)
assertTemplateUsed(resp, "account/messages/email_confirmation_sent.txt")
def test_verify_unknown_email(auth_client, user):
auth_client.post(
reverse("account_email"),
{"action_send": "", "email": "email@unknown.org"},
)
# This unknown email address must not be implicitly added.
assert EmailAddress.objects.filter(user=user).count() == 1
def test_add_with_two_limiter(auth_client, user, settings):
EmailAddress.objects.create(
email="secondary@email.org", user=user, verified=False, primary=False
)
settings.ACCOUNT_MAX_EMAIL_ADDRESSES = 2
resp = auth_client.post(
reverse("account_email"), {"action_add": "", "email": "john3@example.org"}
)
assertTemplateNotUsed(resp, "account/messages/email_confirmation_sent.txt")
def test_add_with_none_limiter(auth_client, settings):
settings.ACCOUNT_MAX_EMAIL_ADDRESSES = None
resp = auth_client.post(
reverse("account_email"), {"action_add": "", "email": "john3@example.org"}
)
assertTemplateUsed(resp, "account/messages/email_confirmation_sent.txt")
def test_add_with_zero_limiter(auth_client, settings):
settings.ACCOUNT_MAX_EMAIL_ADDRESSES = 0
resp = auth_client.post(
reverse("account_email"), {"action_add": "", "email": "john3@example.org"}
)
assertTemplateUsed(resp, "account/messages/email_confirmation_sent.txt")
@pytest.mark.parametrize("has_email_field", [True, False])
def test_set_email_as_primary_doesnt_override_existing_changes_on_the_user(
db, has_email_field, settings
):
if not has_email_field:
settings.ACCOUNT_USER_MODEL_EMAIL_FIELD = None
user = get_user_model().objects.create(
username="@raymond.penners", first_name="Before Update"
)
email = EmailAddress.objects.create(
user=user,
email="raymond.penners@example.com",
primary=True,
verified=True,
)
updated_first_name = "Updated"
get_user_model().objects.filter(id=user.id).update(first_name=updated_first_name)
email.set_as_primary()
user.refresh_from_db()
assert user.first_name == updated_first_name
def test_delete_email_changes_user_email(user_factory, client, email_factory):
user = user_factory(email_verified=False)
client.force_login(user)
first_email = EmailAddress.objects.get(user=user)
first_email.primary = False
first_email.save()
# other_unverified_email
EmailAddress.objects.create(
user=user, email=email_factory(), verified=False, primary=False
)
other_verified_email = EmailAddress.objects.create(
user=user, email=email_factory(), verified=True, primary=False
)
assert user_email(user) == first_email.email
resp = client.post(
reverse("account_email"),
{"action_remove": "", "email": first_email.email},
)
assert resp.status_code == 302
user.refresh_from_db()
assert user_email(user) == other_verified_email.email
def test_delete_email_wipes_user_email(user_factory, client):
user = user_factory(email_verified=False)
client.force_login(user)
first_email = EmailAddress.objects.get(user=user)
first_email.primary = False
first_email.save()
assert user_email(user) == first_email.email
resp = client.post(
reverse("account_email"),
{"action_remove": "", "email": first_email.email},
)
assert resp.status_code == 302
user.refresh_from_db()
assert user_email(user) == ""
def test_change_email(user_factory, client, settings, mailoutbox):
settings.ACCOUNT_CHANGE_EMAIL = True
settings.ACCOUNT_EMAIL_CONFIRMATION_HMAC = True
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
user = user_factory(email_verified=True)
client.force_login(user)
current_email = EmailAddress.objects.get(user=user)
resp = client.post(
reverse("account_email"),
{"action_add": "", "email": "change-to@this.org"},
)
assert resp.status_code == 302
assert len(mailoutbox) == 1
assert mailoutbox[0].subject == "[example.com] Please Confirm Your Email Address"
new_email = EmailAddress.objects.get(email="change-to@this.org")
key = EmailConfirmationHMAC(new_email).key
with patch("allauth.account.signals.email_changed.send") as email_changed_mock:
resp = client.post(reverse("account_confirm_email", args=[key]))
assert resp.status_code == 302
assert not EmailAddress.objects.filter(pk=current_email.pk).exists()
assert EmailAddress.objects.filter(user=user).count() == 1
new_email.refresh_from_db()
assert new_email.verified
assert new_email.primary
assert email_changed_mock.called
assert len(mailoutbox) == 2
assert mailoutbox[1].subject == "[example.com] Email Changed"
assert mailoutbox[1].to == [user.email]
def test_add(auth_client, user, settings):
resp = auth_client.post(
reverse("account_email"),
{"action_add": "", "email": "john3@example.org"},
)
EmailAddress.objects.get(
email="john3@example.org",
user=user,
verified=False,
primary=False,
)
assertTemplateUsed(resp, "account/messages/email_confirmation_sent.txt")
def test_add_with_reauthentication(auth_client, user, user_password, settings):
settings.ACCOUNT_REAUTHENTICATION_REQUIRED = True
resp = auth_client.post(
reverse("account_email"),
{"action_add": "", "email": "john3@example.org"},
)
assert not EmailAddress.objects.filter(email="john3@example.org").exists()
assert resp.status_code == 302
assert (
resp["location"]
== reverse("account_reauthenticate") + "?next=%2Faccounts%2Femail%2F"
)
resp = auth_client.post(resp["location"], {"password": user_password})
assert EmailAddress.objects.filter(email="john3@example.org").exists()
assertTemplateUsed(resp, "account/messages/email_confirmation_sent.txt")
assert resp.status_code == 302
assert resp["location"] == reverse("account_email")
@pytest.mark.parametrize(
"prevent_enumeration",
[
False,
True,
"strict",
],
)
def test_add_not_allowed(
auth_client, user, settings, user_factory, prevent_enumeration
):
settings.ACCOUNT_PREVENT_ENUMERATION = prevent_enumeration
email = "inuse@byotheruser.com"
user_factory(email=email)
resp = auth_client.post(
reverse("account_email"),
{"action_add": "", "email": email},
)
if prevent_enumeration:
assert resp.status_code == 302
email_address = EmailAddress.objects.get(
email=email,
user=user,
verified=False,
primary=False,
)
assertTemplateUsed(resp, "account/messages/email_confirmation_sent.txt")
key = EmailConfirmationHMAC(email_address).key
resp = auth_client.post(reverse("account_confirm_email", args=[key]))
assertTemplateUsed(resp, "account/messages/email_confirmation_failed.txt")
assert resp.status_code == 302
email_address.refresh_from_db()
assert not email_address.verified
else:
assert resp.status_code == 200
assert resp.context["form"].errors == {
"email": ["A user is already registered with this email address."]
}
@pytest.mark.parametrize(
"authentication_method,primary_email,secondary_emails,delete_email,success",
[
(AuthenticationMethod.EMAIL, "pri@ma.il", ["sec@ma.il"], "pri@ma.il", False),
(AuthenticationMethod.EMAIL, "pri@ma.il", ["sec@ma.il"], "sec@ma.il", True),
(AuthenticationMethod.EMAIL, "pri@ma.il", [], "pri@ma.il", False),
(AuthenticationMethod.USERNAME, "pri@ma.il", ["sec@ma.il"], "pri@ma.il", False),
(AuthenticationMethod.USERNAME, "pri@ma.il", ["sec@ma.il"], "sec@ma.il", True),
(AuthenticationMethod.USERNAME, "pri@ma.il", [], "pri@ma.il", True),
(
AuthenticationMethod.USERNAME_EMAIL,
"pri@ma.il",
["sec@ma.il"],
"pri@ma.il",
False,
),
(
AuthenticationMethod.USERNAME_EMAIL,
"pri@ma.il",
["sec@ma.il"],
"sec@ma.il",
True,
),
(AuthenticationMethod.USERNAME_EMAIL, "pri@ma.il", [], "pri@ma.il", True),
],
)
def test_remove_email(
client,
settings,
user_factory,
primary_email,
secondary_emails,
delete_email,
authentication_method,
success,
):
settings.ACCOUNT_AUTHENTICATION_METHOD = authentication_method
user = user_factory(email=primary_email)
EmailAddress.objects.bulk_create(
[
EmailAddress(user=user, email=email, primary=False, verified=False)
for email in secondary_emails
]
)
client.force_login(user)
resp = client.post(
reverse("account_email"),
{"action_remove": "", "email": delete_email},
)
assert EmailAddress.objects.filter(email=delete_email).exists() == (not success)
if not success:
assertTemplateUsed(resp, "account/messages/cannot_delete_primary_email.txt")
@pytest.mark.parametrize(
"email,did_look_up",
[
("valid@email.org", True),
("not-an-email", False),
],
)
def test_dont_lookup_invalid_email(auth_client, email, did_look_up):
with patch("allauth.account.views.EmailAddress.objects.get_for_user") as gfu_mock:
gfu_mock.side_effect = EmailAddress.DoesNotExist
auth_client.post(
reverse("account_email"),
{"action_remove": "", "email": email},
)
assert gfu_mock.called == did_look_up
def test_add_requires_reauthentication(settings, auth_client):
settings.ACCOUNT_REAUTHENTICATION_REQUIRED = True
resp = auth_client.post(
reverse("account_email"),
{"action_add": "", "email": "john3@example.org"},
)
assert not EmailAddress.objects.filter(email="john3@example.org").exists()
assert resp["location"].startswith(reverse("account_reauthenticate"))
def test_remove_requires_reauthentication(auth_client, user, settings):
settings.ACCOUNT_REAUTHENTICATION_REQUIRED = True
secondary = EmailAddress.objects.create(
email="secondary@email.org", user=user, verified=False, primary=False
)
resp = auth_client.post(
reverse("account_email"),
{"action_remove": "", "email": secondary.email},
)
assert resp["location"].startswith(reverse("account_reauthenticate"))
assert EmailAddress.objects.filter(pk=secondary.pk).exists()
def test_set_primary_requires_reauthentication(auth_client, user, settings):
settings.ACCOUNT_REAUTHENTICATION_REQUIRED = True
primary = EmailAddress.objects.get(email=user.email)
secondary = EmailAddress.objects.create(
email="secondary@email.org", user=user, verified=True, primary=False
)
resp = auth_client.post(
reverse("account_email"),
{"action_primary": "", "email": secondary.email},
)
assert resp["location"].startswith(reverse("account_reauthenticate"))
primary.refresh_from_db()
secondary.refresh_from_db()
assert primary.primary
assert not secondary.primary

View File

@@ -0,0 +1,102 @@
from django.urls import reverse, reverse_lazy
import pytest
def test_change_unusable_password_redirects_to_set(client, user, user_password):
user.set_unusable_password()
user.save()
client.force_login(user)
resp = client.get(reverse("account_change_password"))
assert resp.status_code == 302
assert resp["location"] == reverse("account_set_password")
def test_set_usable_password_redirects_to_change(auth_client, user):
resp = auth_client.get(reverse("account_set_password"))
assert resp.status_code == 302
assert resp["location"] == reverse("account_change_password")
@pytest.mark.parametrize(
"logout,next_url,redirect_chain",
[
(False, "", [(reverse_lazy("account_change_password"), 302)]),
(False, "/foo", [("/foo", 302)]),
(
True,
"",
[
(reverse_lazy("account_change_password"), 302),
(
"/accounts/login/?next=/accounts/password/change/",
302,
),
],
),
(True, "/foo", [("/foo", 302)]),
],
)
def test_set_password(
client, user, next_url, password_factory, logout, settings, redirect_chain
):
settings.ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = logout
user.set_unusable_password()
user.save()
client.force_login(user)
password = password_factory()
data = {"password1": password, "password2": password}
if next_url:
data["next"] = next_url
resp = client.post(
reverse("account_set_password"),
data,
follow=True,
)
assert resp.redirect_chain == redirect_chain
@pytest.mark.parametrize(
"logout,next_url,redirect_chain",
[
(False, "", [(reverse_lazy("account_change_password"), 302)]),
(False, "/foo", [("/foo", 302)]),
(
True,
"",
[
(reverse_lazy("account_change_password"), 302),
(
"/accounts/login/?next=/accounts/password/change/",
302,
),
],
),
(True, "/foo", [("/foo", 302)]),
],
)
def test_change_password(
auth_client,
user,
user_password,
next_url,
password_factory,
logout,
settings,
redirect_chain,
mailoutbox,
):
settings.ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = logout
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
password = password_factory()
data = {"oldpassword": user_password, "password1": password, "password2": password}
if next_url:
data["next"] = next_url
resp = auth_client.post(
reverse("account_change_password"),
data,
follow=True,
)
assert resp.redirect_chain == redirect_chain
assert len(mailoutbox) == 1
assert "Your password has been changed" in mailoutbox[0].body

View File

@@ -0,0 +1,7 @@
from django.core.management import call_command
def test_unset_multipleprimaryemails(db):
# This command needs to be dropped, in favor of having a conditional
# constraint.
call_command("account_unsetmultipleprimaryemails")

View File

@@ -0,0 +1,39 @@
from django.urls import reverse
from pytest_django.asserts import assertTemplateUsed
from allauth.account.decorators import verified_email_required
def test_verified_email_required(user_factory, request_factory):
user = user_factory(email_verified=False)
@verified_email_required
def view(request):
raise AssertionError()
request = request_factory.get("/")
request.user = user
view(request)
assertTemplateUsed("account/verified_email_required.html")
def test_secure_admin_login_skips_admin_login_next(client):
"""
Test that we're not using 'next=/admin/login%2Fnext=/foo'
"""
resp = client.get(reverse("admin:login") + "?next=/foo")
assert resp["location"] == "/accounts/login/?next=%2Ffoo"
def test_secure_admin_login_denies_regular_users(auth_client):
resp = auth_client.get(reverse("admin:login"))
assert resp.status_code == 403
def test_secure_admin_login_passes_staff(auth_client, user):
user.is_staff = True
user.is_superuser = True
user.save(update_fields=["is_staff", "is_superuser"])
resp = auth_client.get(reverse("admin:auth_user_changelist"))
assert resp.status_code == 200

View File

@@ -0,0 +1,362 @@
from datetime import timedelta
from unittest.mock import Mock
from django.contrib.auth import SESSION_KEY, get_user_model
from django.core.cache import cache
from django.urls import reverse
from django.utils.timezone import now
import pytest
from pytest_django.asserts import (
assertRedirects,
assertTemplateNotUsed,
assertTemplateUsed,
)
from allauth.account import app_settings
from allauth.account.adapter import DefaultAccountAdapter
from allauth.account.models import (
EmailAddress,
EmailConfirmation,
EmailConfirmationHMAC,
)
from allauth.account.signals import user_logged_in
class TestEmailVerificationAdapter(DefaultAccountAdapter):
SIGNUP_REDIRECT_URL = "/foobar"
def get_signup_redirect_url(self, request):
return self.SIGNUP_REDIRECT_URL
@pytest.mark.parametrize(
"adapter,query,expected_location",
[
(None, "", app_settings.SIGNUP_REDIRECT_URL),
(None, "?next=/foo", "/foo"),
(
"allauth.account.tests.test_email_verification.TestEmailVerificationAdapter",
"",
TestEmailVerificationAdapter.SIGNUP_REDIRECT_URL,
),
],
)
def test_login_on_verification(
adapter, client, db, query, expected_location, password_factory, settings
):
settings.ACCOUNT_EMAIL_VERIFICATION = app_settings.EmailVerificationMethod.MANDATORY
settings.ACCOUNT_EMAIL_CONFIRMATION_HMAC = True
settings.ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True
if adapter:
settings.ACCOUNT_ADAPTER = adapter
password = password_factory()
resp = client.post(
reverse("account_signup"),
data={
"username": "john",
"email": "a@a.com",
"password1": password,
"password2": password,
},
)
assert resp.status_code == 302
assert resp["location"] == reverse("account_email_verification_sent")
resp = client.get(resp["location"])
assert resp.status_code == 200
email = EmailAddress.objects.get(email="a@a.com")
key = EmailConfirmationHMAC(email).key
receiver_mock = Mock() # we've logged if signal was called
user_logged_in.connect(receiver_mock)
resp = client.post(reverse("account_confirm_email", args=[key]) + query)
assert resp["location"] == expected_location
email = EmailAddress.objects.get(pk=email.pk)
assert email.verified
receiver_mock.assert_called_once_with(
sender=get_user_model(),
request=resp.wsgi_request,
response=resp,
user=email.user,
signal=user_logged_in,
)
user_logged_in.disconnect(receiver_mock)
def test_email_verification_failed(settings, user_factory, client):
settings.ACCOUNT_EMAIL_CONFIRMATION_HMAC = False
user_factory(email_verified=True, email="foo@bar.org")
unverified_user = user_factory(email_verified=False, email="foo@bar.org")
email_address = EmailAddress.objects.get_for_user(
unverified_user, unverified_user.email
)
assert not email_address.verified
confirmation = EmailConfirmation.objects.create(
email_address=email_address,
key="dummy",
sent=now(),
)
resp = client.post(reverse("account_confirm_email", args=[confirmation.key]))
assertTemplateUsed(resp, "account/messages/email_confirmation_failed.txt")
def test_email_verification_mandatory(settings, db, client, mailoutbox, enable_cache):
settings.ACCOUNT_EMAIL_CONFIRMATION_HMAC = False
settings.ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN = 10
settings.ACCOUNT_EMAIL_VERIFICATION = app_settings.EmailVerificationMethod.MANDATORY
# Signup
resp = client.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": "john@example.com",
"password1": "johndoe",
"password2": "johndoe",
},
follow=True,
)
assert resp.status_code == 200
assert mailoutbox[0].to == ["john@example.com"]
assert mailoutbox[0].body.find("http://") > 0
assert len(mailoutbox) == 1
assertTemplateUsed(
resp,
"account/verification_sent.%s" % app_settings.TEMPLATE_EXTENSION,
)
# Attempt to login, unverified
for attempt in [1, 2]:
resp = client.post(
reverse("account_login"),
{"login": "johndoe", "password": "johndoe"},
follow=True,
)
# is_active is controlled by the admin to manually disable
# users. I don't want this flag to flip automatically whenever
# users verify their email addresses.
assert (
get_user_model().objects.filter(username="johndoe", is_active=True).exists()
)
assertTemplateUsed(
resp,
"account/verification_sent." + app_settings.TEMPLATE_EXTENSION,
)
# Attempt 1: no mail is sent due to cool-down ,
# but there was already a mail in the outbox.
assert len(mailoutbox) == attempt
assert (
EmailConfirmation.objects.filter(
email_address__email="john@example.com"
).count()
== attempt
)
# Wait for cooldown -- wipe cache (incl. rate limits)
cache.clear()
# if we don't wipe the session, login will redirect to pending stage...
client.logout()
# Verify, and re-attempt to login.
confirmation = EmailConfirmation.objects.filter(
email_address__user__username="johndoe"
)[:1].get()
resp = client.get(reverse("account_confirm_email", args=[confirmation.key]))
assertTemplateUsed(
resp, "account/email_confirm.%s" % app_settings.TEMPLATE_EXTENSION
)
client.post(reverse("account_confirm_email", args=[confirmation.key]))
resp = client.post(
reverse("account_login"),
{"login": "johndoe", "password": "johndoe"},
)
assertRedirects(resp, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False)
def test_optional_email_verification(settings, client, db, mailoutbox):
settings.ACCOUNT_SIGNUP_REDIRECT_URL = "/accounts/welcome/"
settings.ACCOUNT_EMAIL_VERIFICATION = app_settings.EmailVerificationMethod.OPTIONAL
settings.ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False
# Signup
client.get(reverse("account_signup"))
resp = client.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": "john@example.com",
"password1": "johndoe",
},
)
# Logged in
assertRedirects(
resp, settings.ACCOUNT_SIGNUP_REDIRECT_URL, fetch_redirect_response=False
)
assert mailoutbox[0].to == ["john@example.com"]
assert len(mailoutbox) == 1
# Logout & login again
client.logout()
# Wait for cooldown
EmailConfirmation.objects.update(sent=now() - timedelta(days=1))
# Signup
resp = client.post(
reverse("account_login"),
{"login": "johndoe", "password": "johndoe"},
)
assertRedirects(resp, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False)
assert mailoutbox[0].to == ["john@example.com"]
# There was an issue that we sent out email confirmation mails
# on each login in case of optional verification. Make sure
# this is not the case:
assert len(mailoutbox) == 1
def test_email_verification_hmac(settings, client, user_factory, mailoutbox, rf):
settings.ACCOUNT_EMAIL_CONFIRMATION_HMAC = True
user = user_factory(email_verified=False)
email = EmailAddress.objects.get_for_user(user, user.email)
confirmation = EmailConfirmationHMAC(email)
request = rf.get("/")
confirmation.send(request=request)
assert len(mailoutbox) == 1
client.post(reverse("account_confirm_email", args=[confirmation.key]))
email = EmailAddress.objects.get(pk=email.pk)
assert email.verified
def test_email_verification_hmac_timeout(
settings, user_factory, client, mailoutbox, rf
):
settings.ACCOUNT_EMAIL_CONFIRMATION_HMAC = True
settings.ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 0
user = user_factory(email_verified=False)
email = EmailAddress.objects.get_for_user(user, user.email)
confirmation = EmailConfirmationHMAC(email)
request = rf.get("/")
confirmation.send(request=request)
assert len(mailoutbox) == 1
client.post(reverse("account_confirm_email", args=[confirmation.key]))
email = EmailAddress.objects.get(pk=email.pk)
assert not email.verified
def test_verify_email_with_another_user_logged_in(
settings, user_factory, client, mailoutbox
):
"""Test the email verification view. If User B clicks on an email
verification link while logged in as User A, ensure User A gets
logged out."""
settings.ACCOUNT_AUTHENTICATION_METHOD = app_settings.AuthenticationMethod.EMAIL
user = user_factory(email_verified=False)
client.force_login(user)
client.post(reverse("account_email"), {"email": user.email, "action_send": ""})
assert len(mailoutbox) == 1
assert mailoutbox[0].to == [user.email]
client.logout()
body = mailoutbox[0].body
assert body.find("http://") > 0
user2 = user_factory(email_verified=False, password="doe")
resp = client.post(
reverse("account_login"),
{
"login": user2.email,
"password": "doe",
},
)
assert user2 == resp.context["user"]
url = body[body.find("/accounts/confirm-email/") :].split()[0]
resp = client.post(url)
assertTemplateUsed(resp, "account/messages/logged_out.txt")
assertTemplateUsed(resp, "account/messages/email_confirmed.txt")
assertRedirects(resp, settings.LOGIN_URL, fetch_redirect_response=False)
def test_verify_email_with_same_user_logged_in(
settings, user_factory, client, mailoutbox
):
"""If the user clicks on an email verification link while logged in, ensure
the user stays logged in.
"""
settings.ACCOUNT_AUTHENTICATION_METHOD = app_settings.AuthenticationMethod.EMAIL
user = user_factory(email_verified=False)
client.force_login(user)
client.post(reverse("account_email"), {"email": user.email, "action_send": ""})
assert len(mailoutbox) == 1
assert mailoutbox[0].to == [user.email]
body = mailoutbox[0].body
assert body.find("http://") > 0
url = body[body.find("/accounts/confirm-email/") :].split()[0]
resp = client.post(url)
assertTemplateNotUsed(resp, "account/messages/logged_out.txt")
assertTemplateUsed(resp, "account/messages/email_confirmed.txt")
assertRedirects(resp, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False)
assert user == resp.wsgi_request.user
def test_verify_logs_out_user(auth_client, settings, user, user_factory):
"""
When a user is signed in, and you follow an email confirmation link of
another user within the same browser/session, be sure to sign out the signed
in user.
"""
settings.ACCOUNT_CONFIRM_EMAIL_ON_GET = False
confirming_user = user_factory(email_verified=False)
assert auth_client.session[SESSION_KEY] == str(user.pk)
email = EmailAddress.objects.get(user=confirming_user, verified=False)
auth_client.get(
reverse(
"account_confirm_email", kwargs={"key": EmailConfirmationHMAC(email).key}
)
)
assert not auth_client.session.get(SESSION_KEY)
def test_email_verification_login_redirect(client, db, settings, password_factory):
settings.ACCOUNT_EMAIL_VERIFICATION = app_settings.EmailVerificationMethod.MANDATORY
password = password_factory()
resp = client.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": "user@email.org",
"password1": password,
"password2": password,
},
)
assert resp.status_code == 302
assert resp["location"] == reverse("account_email_verification_sent")
resp = client.get(reverse("account_login"))
assert resp.status_code == 200
def test_email_verification_redirect_url(client, db, settings, user_password):
settings.ACCOUNT_EMAIL_VERIFICATION = app_settings.EmailVerificationMethod.MANDATORY
settings.ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = "/foobar"
settings.ACCOUNT_CONFIRM_EMAIL_ON_GET = True
email = "user@email.org"
resp = client.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": email,
"password1": user_password,
"password2": user_password,
},
)
assert resp.status_code == 302
assert resp["location"] == reverse("account_email_verification_sent")
confirmation = EmailConfirmationHMAC(EmailAddress.objects.get(email=email))
resp = client.get(reverse("account_confirm_email", args=[confirmation.key]))
assert resp.status_code == 302
assert resp["location"] == "/foobar"

View File

@@ -0,0 +1,182 @@
import re
from unittest.mock import patch
from django.conf import settings
from django.contrib.auth import get_user_model
from django.test import Client
from django.urls import reverse
import pytest
from allauth.account.internal.flows import email_verification_by_code
from allauth.account.models import EmailAddress
@pytest.fixture
def get_last_code(client, mailoutbox):
def f():
code = re.search(
"\n[0-9a-z]{6}\n", mailoutbox[0].body, re.I | re.DOTALL | re.MULTILINE
)[0].strip()
assert (
client.session[
email_verification_by_code.EMAIL_VERIFICATION_CODE_SESSION_KEY
]["code"]
== code
)
return code
return f
@pytest.fixture(autouse=True)
def email_verification_settings(settings):
settings.ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED = True
settings.ACCOUNT_EMAIL_VERIFICATION = "mandatory"
settings.ACCOUNT_AUTHENTICATION_METHOD = "email"
return settings
@pytest.mark.parametrize(
"query,expected_url",
[
("", settings.LOGIN_REDIRECT_URL),
("?next=/foo", "/foo"),
],
)
def test_signup(
client, db, settings, password_factory, get_last_code, query, expected_url
):
password = password_factory()
resp = client.post(
reverse("account_signup") + query,
{
"username": "johndoe",
"email": "john@example.com",
"password1": password,
"password2": password,
},
)
assert get_user_model().objects.filter(username="johndoe").count() == 1
code = get_last_code()
assert resp.status_code == 302
assert resp["location"] == reverse("account_email_verification_sent")
resp = client.get(reverse("account_email_verification_sent"))
assert resp.status_code == 200
resp = client.post(reverse("account_email_verification_sent"), data={"code": code})
assert resp.status_code == 302
assert resp["location"] == expected_url
def test_signup_prevent_enumeration(
client, db, settings, password_factory, user, mailoutbox
):
password = password_factory()
resp = client.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": user.email,
"password1": password,
"password2": password,
},
)
assert resp.status_code == 302
assert resp["location"] == reverse("account_email_verification_sent")
assert not get_user_model().objects.filter(username="johndoe").exists()
assert mailoutbox[0].subject == "[example.com] Account Already Exists"
resp = client.get(reverse("account_email_verification_sent"))
assert resp.status_code == 200
resp = client.post(reverse("account_email_verification_sent"), data={"code": ""})
assert resp.status_code == 200
assert resp.context["form"].errors == {"code": ["This field is required."]}
resp = client.post(reverse("account_email_verification_sent"), data={"code": "123"})
assert resp.status_code == 200
assert resp.context["form"].errors == {"code": ["Incorrect code."]}
# Max attempts
resp = client.post(reverse("account_email_verification_sent"), data={"code": "456"})
assert resp.status_code == 302
assert resp["location"] == reverse("account_login")
@pytest.mark.parametrize("change_email", (False, True))
def test_add_or_change_email(auth_client, user, get_last_code, change_email, settings):
settings.ACCOUNT_CHANGE_EMAIL = change_email
email = "additional@email.org"
assert EmailAddress.objects.filter(user=user).count() == 1
with patch("allauth.account.signals.email_added") as email_added_signal:
with patch("allauth.account.signals.email_changed") as email_changed_signal:
resp = auth_client.post(
reverse("account_email"), {"action_add": "", "email": email}
)
assert resp["location"] == reverse("account_email_verification_sent")
assert not email_added_signal.send.called
assert not email_changed_signal.send.called
assert EmailAddress.objects.filter(email=email).count() == 0
code = get_last_code()
resp = auth_client.get(reverse("account_email_verification_sent"))
assert resp.status_code == 200
with patch("allauth.account.signals.email_added") as email_added_signal:
with patch("allauth.account.signals.email_changed") as email_changed_signal:
with patch(
"allauth.account.signals.email_confirmed"
) as email_confirmed_signal:
resp = auth_client.post(
reverse("account_email_verification_sent"), data={"code": code}
)
assert resp.status_code == 302
assert resp["location"] == reverse("account_email")
assert email_added_signal.send.called
assert email_confirmed_signal.send.called
assert email_changed_signal.send.called == change_email
assert EmailAddress.objects.filter(email=email, verified=True).count() == 1
assert EmailAddress.objects.filter(user=user).count() == (1 if change_email else 2)
def test_email_verification_login_redirect(
client, db, settings, password_factory, email_verification_settings
):
password = password_factory()
resp = client.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": "user@email.org",
"password1": password,
"password2": password,
},
)
assert resp.status_code == 302
assert resp["location"] == reverse("account_email_verification_sent")
resp = client.get(reverse("account_login"))
assert resp["location"] == reverse("account_email_verification_sent")
def test_email_verification_rate_limits(
db,
user_password,
email_verification_settings,
settings,
user_factory,
password_factory,
enable_cache,
):
settings.ACCOUNT_RATE_LIMITS = {"confirm_email": "1/m/key"}
email = "user@email.org"
user_factory(email=email, email_verified=False, password=user_password)
for attempt in range(2):
resp = Client().post(
reverse("account_login"),
{
"login": email,
"password": user_password,
},
)
if attempt == 0:
assert resp.status_code == 302
assert resp["location"] == reverse("account_email_verification_sent")
else:
assert resp.status_code == 200
assert resp.context["form"].errors == {
"__all__": ["Too many failed login attempts. Try again later."]
}

View File

@@ -0,0 +1,345 @@
import json
from unittest.mock import ANY, patch
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core import mail
from django.test.utils import override_settings
from django.urls import NoReverseMatch, reverse
from pytest_django.asserts import assertTemplateUsed
from allauth.account import app_settings
from allauth.account.authentication import AUTHENTICATION_METHODS_SESSION_KEY
from allauth.account.forms import LoginForm
from allauth.account.models import EmailAddress
from allauth.tests import TestCase
@override_settings(
ACCOUNT_DEFAULT_HTTP_PROTOCOL="https",
ACCOUNT_EMAIL_VERIFICATION=app_settings.EmailVerificationMethod.MANDATORY,
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.USERNAME,
ACCOUNT_SIGNUP_FORM_CLASS=None,
ACCOUNT_EMAIL_SUBJECT_PREFIX=None,
LOGIN_REDIRECT_URL="/accounts/profile/",
ACCOUNT_SIGNUP_REDIRECT_URL="/accounts/welcome/",
ACCOUNT_ADAPTER="allauth.account.adapter.DefaultAccountAdapter",
ACCOUNT_USERNAME_REQUIRED=True,
)
class LoginTests(TestCase):
@override_settings(
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.USERNAME_EMAIL
)
def test_username_containing_at(self):
user = get_user_model().objects.create(username="@raymond.penners")
user.set_password("psst")
user.save()
EmailAddress.objects.create(
user=user,
email="raymond.penners@example.com",
primary=True,
verified=True,
)
resp = self.client.post(
reverse("account_login"),
{"login": "@raymond.penners", "password": "psst"},
)
self.assertRedirects(
resp, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False
)
self.assertEqual(
self.client.session[AUTHENTICATION_METHODS_SESSION_KEY],
[
{
"at": ANY,
"username": "@raymond.penners",
"method": "password",
}
],
)
def _create_user(self, username="john", password="doe", **kwargs):
user = get_user_model().objects.create(
username=username, is_active=True, **kwargs
)
if password:
user.set_password(password)
else:
user.set_unusable_password()
user.save()
return user
def _create_user_and_login(self, usable_password=True):
password = "doe" if usable_password else False
user = self._create_user(password=password)
self.client.force_login(user)
return user
def test_redirect_when_authenticated(self):
self._create_user_and_login()
c = self.client
resp = c.get(reverse("account_login"))
self.assertRedirects(resp, "/accounts/profile/", fetch_redirect_response=False)
def test_ajax_password_change(self):
self._create_user_and_login()
resp = self.client.post(
reverse("account_change_password"),
data={
"oldpassword": "doe",
"password1": "AbCdEf!123",
"password2": "AbCdEf!123456",
},
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEqual(resp["content-type"], "application/json")
data = json.loads(resp.content.decode("utf8"))
assert "same password" in data["form"]["fields"]["password2"]["errors"][0]
@override_settings(
ACCOUNT_EMAIL_VERIFICATION=app_settings.EmailVerificationMethod.OPTIONAL
)
def test_login_unverified_account_optional(self):
"""Tests login behavior when email verification is optional."""
user = get_user_model().objects.create(username="john")
user.set_password("doe")
user.save()
EmailAddress.objects.create(
user=user, email="user@example.com", primary=True, verified=False
)
resp = self.client.post(
reverse("account_login"), {"login": "john", "password": "doe"}
)
self.assertRedirects(
resp, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False
)
@override_settings(
ACCOUNT_EMAIL_VERIFICATION=app_settings.EmailVerificationMethod.OPTIONAL,
ACCOUNT_LOGIN_ATTEMPTS_LIMIT=3,
CACHES={
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
}
},
)
def test_login_failed_attempts_exceeded(self):
user = get_user_model().objects.create(username="john")
user.set_password("doe")
user.save()
EmailAddress.objects.create(
user=user, email="user@example.com", primary=True, verified=False
)
for i in range(5):
is_valid_attempt = i == 4
is_locked = i >= 3
resp = self.client.post(
reverse("account_login"),
{
"login": ["john", "John", "JOHN", "JOhn", "joHN"][i],
"password": ("doe" if is_valid_attempt else "wrong"),
},
)
self.assertFormError(
resp.context["form"],
None,
(
"Too many failed login attempts. Try again later."
if is_locked
else "The username and/or password you specified are not correct."
),
)
@override_settings(
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.EMAIL,
ACCOUNT_EMAIL_VERIFICATION=app_settings.EmailVerificationMethod.MANDATORY,
ACCOUNT_LOGIN_ATTEMPTS_LIMIT=1,
CACHES={
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
}
},
)
def test_login_failed_attempts_exceeded_cleared_on_password_reset(self):
# Ensure that login attempts, once they hit the limit,
# can use the password reset mechanism to regain access.
user = get_user_model().objects.create(
username="john", email="john@example.org", is_active=True
)
user.set_password("doe")
user.save()
EmailAddress.objects.create(
user=user, email="john@example.org", primary=True, verified=True
)
resp = self.client.post(
reverse("account_login"), {"login": user.email, "password": "bad"}
)
self.assertFormError(
resp.context["form"],
None,
"The email address and/or password you specified are not correct.",
)
resp = self.client.post(
reverse("account_login"), {"login": user.email, "password": "bad"}
)
self.assertFormError(
resp.context["form"],
None,
"Too many failed login attempts. Try again later.",
)
self.client.post(reverse("account_reset_password"), data={"email": user.email})
body = mail.outbox[0].body
self.assertGreater(body.find("https://"), 0)
# Extract URL for `password_reset_from_key` view and access it
url = body[body.find("/accounts/password/reset/") :].split()[0]
resp = self.client.get(url)
# Follow the redirect the actual password reset page with the key
# hidden.
url = resp.url
resp = self.client.get(url)
self.assertTemplateUsed(
resp,
"account/password_reset_from_key.%s" % app_settings.TEMPLATE_EXTENSION,
)
self.assertFalse("token_fail" in resp.context_data)
new_password = "newpass123"
# Reset the password
resp = self.client.post(
url, {"password1": new_password, "password2": new_password}
)
self.assertRedirects(resp, reverse("account_reset_password_from_key_done"))
# Check the new password is in effect
user = get_user_model().objects.get(pk=user.pk)
self.assertTrue(user.check_password(new_password))
resp = self.client.post(
reverse("account_login"),
{"login": user.email, "password": new_password},
)
self.assertRedirects(
resp, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False
)
@override_settings(
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.EMAIL,
ACCOUNT_EMAIL_VERIFICATION=app_settings.EmailVerificationMethod.MANDATORY,
ACCOUNT_LOGIN_ATTEMPTS_LIMIT=1,
)
def test_login_using_unverified_email_address_is_prohibited(self):
user = get_user_model().objects.create(
username="john", email="john@example.org", is_active=True
)
user.set_password("doe")
user.save()
EmailAddress.objects.create(
user=user, email="john@example.org", primary=True, verified=True
)
EmailAddress.objects.create(
user=user, email="john@example.com", primary=False, verified=False
)
resp = self.client.post(
reverse("account_login"), {"login": "john@example.com", "password": "doe"}
)
self.assertRedirects(
resp,
reverse("account_email_verification_sent"),
fetch_redirect_response=False,
)
self.assertEqual(len(mail.outbox), 1)
assert mail.outbox[0].to == ["john@example.com"]
def test_login_unverified_account_mandatory(self):
"""Tests login behavior when email verification is mandatory."""
user = get_user_model().objects.create(username="john")
user.set_password("doe")
user.save()
EmailAddress.objects.create(
user=user, email="user@example.com", primary=True, verified=False
)
resp = self.client.post(
reverse("account_login"), {"login": "john", "password": "doe"}
)
self.assertRedirects(resp, reverse("account_email_verification_sent"))
def test_login_inactive_account(self):
"""
Tests login behavior with inactive accounts.
Inactive user accounts should be prevented from performing any actions,
regardless of their verified state.
"""
# Inactive and verified user account
user = get_user_model().objects.create(username="john", is_active=False)
user.set_password("doe")
user.save()
EmailAddress.objects.create(
user=user, email="john@example.com", primary=True, verified=True
)
resp = self.client.post(
reverse("account_login"), {"login": "john", "password": "doe"}
)
self.assertRedirects(resp, reverse("account_inactive"))
# Inactive and unverified user account
user = get_user_model().objects.create(username="doe", is_active=False)
user.set_password("john")
user.save()
EmailAddress.objects.create(
user=user, email="user@example.com", primary=True, verified=False
)
resp = self.client.post(
reverse("account_login"), {"login": "doe", "password": "john"}
)
self.assertRedirects(resp, reverse("account_inactive"))
@override_settings(ACCOUNT_AUTHENTICATED_LOGIN_REDIRECTS=False)
def test_account_authenticated_login_redirects_is_false(self):
self._create_user_and_login()
resp = self.client.get(reverse("account_login"))
self.assertEqual(resp.status_code, 200)
def test_login_password_forgotten_link_not_present(client, db):
with patch("allauth.account.forms.reverse") as reverse_mock:
reverse_mock.side_effect = NoReverseMatch
form = LoginForm()
assert form.fields["password"].help_text == ""
def test_login_password_forgotten_link_present(client, db):
form = LoginForm()
assert (
form.fields["password"].help_text
== '<a href="/accounts/password/reset/">Forgot your password?</a>'
)
def test_login_while_authenticated(settings, client, user_factory):
settings.ACCOUNT_AUTHENTICATED_LOGIN_REDIRECTS = False
user_factory(username="john", email="john@example.org", password="doe")
user_factory(username="jane", email="jane@example.org", password="doe")
redirect_url = settings.LOGIN_REDIRECT_URL
resp = client.post(reverse("account_login"), {"login": "john", "password": "doe"})
assert resp.status_code == 302
assert resp["location"] == redirect_url
resp = client.post(reverse("account_login"), {"login": "jane", "password": "doe"})
assert resp.status_code == 302
assert resp["location"] == redirect_url
def test_login_page(client, db):
resp = client.get(reverse("account_login"))
assert resp.status_code == 200
assertTemplateUsed(resp, "account/login.html")

View File

@@ -0,0 +1,117 @@
from unittest.mock import ANY
from django.urls import reverse
import pytest
from allauth.account.authentication import AUTHENTICATION_METHODS_SESSION_KEY
from allauth.account.internal.flows.login_by_code import LOGIN_CODE_STATE_KEY
from allauth.account.internal.stagekit import LOGIN_SESSION_KEY
from allauth.account.models import EmailAddress
@pytest.fixture
def request_login_by_code(mailoutbox):
def f(client, email):
resp = client.get(reverse("account_request_login_code") + "?next=/foo")
assert resp.status_code == 200
assert b'value="/foo"' in resp.content
resp = client.post(
reverse("account_request_login_code"), data={"email": email, "next": "/foo"}
)
assert resp.status_code == 302
assert (
resp["location"] == reverse("account_confirm_login_code") + "?next=%2Ffoo"
)
assert len(mailoutbox) == 1
code = client.session[LOGIN_SESSION_KEY]["state"][LOGIN_CODE_STATE_KEY]["code"]
assert len(code) == 6
assert code in mailoutbox[0].body
return code
return f
def test_login_by_code(client, user, request_login_by_code):
code = request_login_by_code(client, user.email)
code_with_ws = " " + code[0:3] + " " + code[3:]
resp = client.post(
reverse("account_confirm_login_code"),
data={"code": code_with_ws, "next": "/foo"},
)
assert resp.status_code == 302
assert LOGIN_SESSION_KEY not in client.session
assert resp["location"] == "/foo"
assert client.session[AUTHENTICATION_METHODS_SESSION_KEY][-1] == {
"method": "code",
"email": user.email,
"at": ANY,
}
def test_login_by_code_max_attempts(client, user, request_login_by_code, settings):
settings.ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS = 2
request_login_by_code(client, user.email)
for i in range(3):
resp = client.post(
reverse("account_confirm_login_code"), data={"code": "wrong"}
)
if i >= 1:
assert resp.status_code == 302
assert resp["location"] == reverse("account_request_login_code")
assert LOGIN_SESSION_KEY not in client.session
else:
assert resp.status_code == 200
assert LOGIN_SESSION_KEY in client.session
assert resp.context["form"].errors == {"code": ["Incorrect code."]}
def test_login_by_code_unknown_user(mailoutbox, client, db):
resp = client.post(
reverse("account_request_login_code"),
data={"email": "unknown@email.org"},
)
assert resp.status_code == 302
assert resp["location"] == reverse("account_confirm_login_code")
resp = client.post(reverse("account_confirm_login_code"), data={"code": "123456"})
@pytest.mark.parametrize(
"setting,code_required",
[
(True, True),
({"password"}, True),
({"socialaccount"}, False),
],
)
def test_login_by_code_required(
client, settings, user_factory, password_factory, setting, code_required
):
password = password_factory()
user = user_factory(password=password, email_verified=False)
email_address = EmailAddress.objects.get(email=user.email)
assert not email_address.verified
settings.ACCOUNT_LOGIN_BY_CODE_REQUIRED = setting
resp = client.post(
reverse("account_login"),
data={"login": user.username, "password": password},
)
assert resp.status_code == 302
if code_required:
assert resp["location"] == reverse("account_confirm_login_code")
code = client.session[LOGIN_SESSION_KEY]["state"][LOGIN_CODE_STATE_KEY]["code"]
resp = client.get(
reverse("account_confirm_login_code"),
data={"login": user.username, "password": password},
)
assert resp.status_code == 200
resp = client.post(reverse("account_confirm_login_code"), data={"code": code})
email_address.refresh_from_db()
assert email_address.verified
assert resp["location"] == settings.LOGIN_REDIRECT_URL
def test_login_by_code_redirect(client, user, request_login_by_code):
request_login_by_code(client, user.email)
resp = client.get(reverse("account_login"))
assert resp["location"] == reverse("account_confirm_login_code")

View File

@@ -0,0 +1,63 @@
from django.contrib.auth import get_user_model
from django.core import validators
from django.test.client import Client
from django.test.utils import override_settings
from django.urls import reverse
from allauth.account import app_settings
from allauth.account.signals import user_logged_out
from allauth.tests import Mock, TestCase
test_username_validators = [
validators.RegexValidator(regex=r"^[a-c]+$", message="not abc")
]
@override_settings(
ACCOUNT_DEFAULT_HTTP_PROTOCOL="https",
ACCOUNT_EMAIL_VERIFICATION=app_settings.EmailVerificationMethod.MANDATORY,
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.USERNAME,
ACCOUNT_SIGNUP_FORM_CLASS=None,
ACCOUNT_EMAIL_SUBJECT_PREFIX=None,
LOGIN_REDIRECT_URL="/accounts/profile/",
ACCOUNT_SIGNUP_REDIRECT_URL="/accounts/welcome/",
ACCOUNT_ADAPTER="allauth.account.adapter.DefaultAccountAdapter",
ACCOUNT_USERNAME_REQUIRED=True,
)
class LogoutTests(TestCase):
@override_settings(ACCOUNT_LOGOUT_ON_GET=True)
def test_logout_view_on_get(self):
c, resp = self._logout_view("get")
self.assertTemplateUsed(resp, "account/messages/logged_out.txt")
@override_settings(ACCOUNT_LOGOUT_ON_GET=False)
def test_logout_view_on_post(self):
c, resp = self._logout_view("get")
self.assertTemplateUsed(
resp, "account/logout.%s" % app_settings.TEMPLATE_EXTENSION
)
receiver_mock = Mock()
user_logged_out.connect(receiver_mock)
resp = c.post(reverse("account_logout"))
self.assertTemplateUsed(resp, "account/messages/logged_out.txt")
receiver_mock.assert_called_once_with(
sender=get_user_model(),
request=resp.wsgi_request,
user=get_user_model().objects.get(username="john"),
signal=user_logged_out,
)
user_logged_out.disconnect(receiver_mock)
def _logout_view(self, method):
c = Client()
user = get_user_model().objects.create(username="john", is_active=True)
user.set_password("doe")
user.save()
c = Client()
c.login(username="john", password="doe")
return c, getattr(c, method)(reverse("account_logout"))

View File

@@ -0,0 +1,39 @@
import django
from django.http import HttpResponse
from django.test.client import AsyncClient
from django.urls import path, reverse
import pytest
from allauth.account.internal.decorators import login_not_required
from allauth.core.exceptions import ImmediateHttpResponse
@login_not_required
def raise_immediate_http_response(request):
response = HttpResponse(content="raised-response")
raise ImmediateHttpResponse(response=response)
urlpatterns = [path("raise", raise_immediate_http_response)]
def test_immediate_http_response(settings, client):
settings.ROOT_URLCONF = "allauth.account.tests.test_middleware"
resp = client.get("/raise")
assert resp.content == b"raised-response"
skip_django_lt_5 = pytest.mark.skipif(
django.VERSION[0] < 5, reason="This test is allowed to fail on Django <5."
)
@skip_django_lt_5
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
async def test_accounts_redirect_async_ctx(user, db):
aclient = AsyncClient()
await aclient.aforce_login(user)
resp = await aclient.get("/accounts/")
assert resp["location"] == reverse("account_email")

View File

@@ -0,0 +1,27 @@
import uuid
from django.contrib.auth.models import AbstractUser
from django.db import models
from allauth.account.models import EmailAddress
class UUIDUser(AbstractUser):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
class Meta(AbstractUser.Meta): # type: ignore[name-defined]
swappable = "AUTH_USER_MODEL"
def test_add_new_email(rf, user, settings):
settings.ACCOUNT_CHANGE_EMAIL = True
request = rf.get("/")
assert EmailAddress.objects.filter(user=user).count() == 1
new_email = EmailAddress.objects.add_new_email(request, user, "new@email.org")
assert not new_email.verified
assert not new_email.primary
assert EmailAddress.objects.filter(user=user).count() == 2
EmailAddress.objects.add_new_email(request, user, "new2@email.org")
assert EmailAddress.objects.filter(user=user).count() == 2
new_email.refresh_from_db()
assert new_email.email == "new2@email.org"

View File

@@ -0,0 +1,10 @@
from django.urls import reverse
def test_case_insensitive_password_reset(settings, enable_cache, user_factory, client):
settings.ACCOUNT_RATE_LIMITS = {"reset_password": "1/m"}
user_factory(email="a@b.com")
resp = client.post(reverse("account_reset_password"), data={"email": "a@b.com"})
assert resp.status_code == 302
resp = client.post(reverse("account_reset_password"), data={"email": "A@B.COM"})
assert resp.status_code == 429

View File

@@ -0,0 +1,64 @@
from unittest.mock import ANY
from django.urls import reverse
import pytest
from pytest_django.asserts import assertTemplateUsed
from allauth import app_settings as allauth_settings
from allauth.account.adapter import get_adapter
from allauth.account.authentication import AUTHENTICATION_METHODS_SESSION_KEY
@pytest.mark.parametrize(
"with_totp,with_password,expected_method_urlnames",
[
(False, True, ["account_reauthenticate"]),
(True, True, ["account_reauthenticate", "mfa_reauthenticate"]),
(True, False, ["mfa_reauthenticate"]),
],
)
def test_user_with_mfa_only(
user_factory, with_totp, with_password, expected_method_urlnames, client
):
if not allauth_settings.MFA_ENABLED and with_totp:
return
user = user_factory(with_totp=with_totp, password=None if with_password else "!")
assert user.has_usable_password() == with_password
client.force_login(user)
methods = get_adapter().get_reauthentication_methods(user)
assert len(methods) == len(expected_method_urlnames)
assert set([m["url"] for m in methods]) == set(
map(reverse, expected_method_urlnames)
)
for urlname in ["account_reauthenticate", "mfa_reauthenticate"]:
if urlname == "mfa_reauthenticate" and not allauth_settings.MFA_ENABLED:
continue
resp = client.get(reverse(urlname) + "?next=/foo")
if urlname in expected_method_urlnames:
assert resp.status_code == 200
else:
assert resp.status_code == 302
assert "next=%2Ffoo" in resp["location"]
def test_reauthentication(settings, auth_client, user_password):
settings.ACCOUNT_REAUTHENTICATION_REQUIRED = True
resp = auth_client.post(
reverse("account_email"),
{"action_add": "", "email": "john3@example.org"},
)
assert resp["location"].startswith(reverse("account_reauthenticate"))
resp = auth_client.get(reverse("account_reauthenticate"))
assertTemplateUsed(resp, "account/reauthenticate.html")
resp = auth_client.post(
reverse("account_reauthenticate"), data={"password": user_password}
)
assert resp.status_code == 302
resp = auth_client.post(
reverse("account_email"),
{"action_add": "", "email": "john3@example.org"},
)
assert resp["location"].startswith(reverse("account_email"))
methods = auth_client.session[AUTHENTICATION_METHODS_SESSION_KEY]
assert methods[-1] == {"method": "password", "at": ANY, "reauthenticated": True}

View File

@@ -0,0 +1,359 @@
import json
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.core import mail
from django.test.utils import override_settings
from django.urls import reverse, reverse_lazy
from django.utils.http import urlencode
import pytest
from pytest_django.asserts import assertRedirects, assertTemplateUsed
from allauth.account import app_settings
from allauth.account.forms import ResetPasswordForm, default_token_generator
from allauth.account.models import EmailAddress
from allauth.account.utils import user_pk_to_url_str
from allauth.tests import TestCase
@pytest.fixture
def password_reset_url():
def f(user):
temp_key = default_token_generator.make_token(user)
uid = user_pk_to_url_str(user)
return reverse(
"account_reset_password_from_key", kwargs={"uidb36": uid, "key": temp_key}
)
return f
@pytest.mark.django_db
def test_reset_password_unknown_account(client, settings):
settings.ACCOUNT_PREVENT_ENUMERATION = True
client.post(
reverse("account_reset_password"),
data={"email": "unknown@example.org"},
)
assert len(mail.outbox) == 1
assert mail.outbox[0].to == ["unknown@example.org"]
@pytest.mark.django_db
def test_reset_password_unknown_account_disabled(client, settings):
settings.ACCOUNT_PREVENT_ENUMERATION = True
settings.ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
client.post(
reverse("account_reset_password"),
data={"email": "unknown@example.org"},
)
assert len(mail.outbox) == 0
@pytest.mark.parametrize(
"query,expected_location",
[("", reverse_lazy("account_reset_password_done")), ("?next=/foo", "/foo")],
)
def test_reset_password_next_url(client, user, query, expected_location):
resp = client.post(
reverse("account_reset_password") + query,
data={"email": user.email},
)
assert resp["location"] == expected_location
@override_settings(
ACCOUNT_PREVENT_ENUMERATION=False,
ACCOUNT_DEFAULT_HTTP_PROTOCOL="https",
ACCOUNT_EMAIL_VERIFICATION=app_settings.EmailVerificationMethod.MANDATORY,
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.USERNAME,
ACCOUNT_SIGNUP_FORM_CLASS=None,
ACCOUNT_EMAIL_SUBJECT_PREFIX=None,
LOGIN_REDIRECT_URL="/accounts/profile/",
ACCOUNT_SIGNUP_REDIRECT_URL="/accounts/welcome/",
ACCOUNT_ADAPTER="allauth.account.adapter.DefaultAccountAdapter",
ACCOUNT_USERNAME_REQUIRED=True,
ACCOUNT_EMAIL_NOTIFICATIONS=True,
)
class ResetPasswordTests(TestCase):
def test_user_email_not_sent_inactive_user(self):
User = get_user_model()
User.objects.create_user(
"mike123", "mike@ixample.org", "test123", is_active=False
)
data = {"email": "mike@ixample.org"}
form = ResetPasswordForm(data)
self.assertFalse(form.is_valid())
def test_password_reset_get(self):
resp = self.client.get(reverse("account_reset_password"))
self.assertTemplateUsed(resp, "account/password_reset.html")
def test_set_password_not_allowed(self):
user = self._create_user_and_login(True)
pwd = "!*123i1uwn12W23"
self.assertFalse(user.check_password(pwd))
resp = self.client.post(
reverse("account_set_password"),
data={"password1": pwd, "password2": pwd},
)
user.refresh_from_db()
self.assertFalse(user.check_password(pwd))
self.assertTrue(user.has_usable_password())
self.assertEqual(resp.status_code, 302)
def test_password_forgotten_username_hint(self):
user = self._request_new_password()
body = mail.outbox[0].body
assert user.username in body
@override_settings(
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.EMAIL
)
def test_password_forgotten_no_username_hint(self):
user = self._request_new_password()
body = mail.outbox[0].body
assert user.username not in body
def _request_new_password(self):
user = get_user_model().objects.create(
username="john", email="john@example.org", is_active=True
)
user.set_password("doe")
user.save()
self.client.post(
reverse("account_reset_password"),
data={"email": "john@example.org"},
)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["john@example.org"])
return user
def test_password_reset_flow_with_empty_session(self):
"""
Test the password reset flow when the session is empty:
requesting a new password, receiving the reset link via email,
following the link, getting redirected to the
new link (without the token)
Copying the link and using it in a DIFFERENT client (Browser/Device).
"""
# Request new password
self._request_new_password()
body = mail.outbox[0].body
self.assertGreater(body.find("https://"), 0)
# Extract URL for `password_reset_from_key` view
url = body[body.find("/accounts/password/reset/") :].split()[0]
resp = self.client.get(url)
reset_pass_url = resp.url
# Accessing the url via a different session
resp = self.client_class().get(reset_pass_url)
# We should receive the token_fail context_data
self.assertTemplateUsed(
resp,
"account/password_reset_from_key.%s" % app_settings.TEMPLATE_EXTENSION,
)
self.assertTrue(resp.context_data["token_fail"])
@override_settings(
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.EMAIL
)
def test_password_reset_flow_with_another_user_logged_in(self):
"""
Tests the password reset flow: if User B requested a password
reset earlier and now User A is logged in, User B now clicks on
the link, ensure User A is logged out before continuing.
"""
# Request new password
self._request_new_password()
body = mail.outbox[0].body
self.assertGreater(body.find("https://"), 0)
user2 = self._create_user(username="john2", email="john2@example.com")
EmailAddress.objects.create(
user=user2, email=user2.email, primary=True, verified=True
)
resp = self.client.post(
reverse("account_login"),
{
"login": user2.email,
"password": "doe",
},
)
self.assertEqual(user2, resp.context["user"])
# Extract URL for `password_reset_from_key` view and access it
url = body[body.find("/accounts/password/reset/") :].split()[0]
resp = self.client.get(url)
# Follow the redirect the actual password reset page with the key
# hidden.
url = resp.url
resp = self.client.get(url)
self.assertTemplateUsed(
resp, "account/password_reset_from_key.%s" % app_settings.TEMPLATE_EXTENSION
)
self.assertFalse("token_fail" in resp.context_data)
# Reset the password
resp = self.client.post(
url, {"password1": "newpass123", "password2": "newpass123"}, follow=True
)
self.assertRedirects(resp, reverse("account_reset_password_from_key_done"))
self.assertNotEqual(user2, resp.context["user"])
self.assertEqual(AnonymousUser(), resp.context["user"])
def test_password_reset_flow_with_email_changed(self):
"""
Test that the password reset token is invalidated if
the user email address was changed.
"""
user = self._request_new_password()
body = mail.outbox[0].body
self.assertGreater(body.find("https://"), 0)
EmailAddress.objects.create(user=user, email="other@email.org")
# Extract URL for `password_reset_from_key` view
url = body[body.find("/accounts/password/reset/") :].split()[0]
resp = self.client.get(url)
self.assertTemplateUsed(
resp,
"account/password_reset_from_key.%s" % app_settings.TEMPLATE_EXTENSION,
)
self.assertTrue("token_fail" in resp.context_data)
@override_settings(ACCOUNT_LOGIN_ON_PASSWORD_RESET=True)
def test_password_reset_ACCOUNT_LOGIN_ON_PASSWORD_RESET(self):
user = self._request_new_password()
body = mail.outbox[0].body
url = body[body.find("/accounts/password/reset/") :].split()[0]
resp = self.client.get(url)
# Follow the redirect the actual password reset page with the key
# hidden.
resp = self.client.post(
resp.url, {"password1": "newpass123", "password2": "newpass123"}
)
self.assertTrue(user.is_authenticated)
# EmailVerificationMethod.MANDATORY sends us to the confirm-email page
self.assertRedirects(resp, "/accounts/confirm-email/")
def _create_user(self, username="john", password="doe", **kwargs):
user = get_user_model().objects.create(
username=username, is_active=True, **kwargs
)
if password:
user.set_password(password)
else:
user.set_unusable_password()
user.save()
return user
def _create_user_and_login(self, usable_password=True):
password = "doe" if usable_password else False
user = self._create_user(password=password)
self.client.force_login(user)
return user
def test_password_reset_flow(client, user, mailoutbox, settings):
"""
Tests the password reset flow: requesting a new password,
receiving the reset link via email and finally resetting the
password to a new value.
"""
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
# Request new password
client.post(
reverse("account_reset_password"),
data={"email": user.email},
)
assert len(mail.outbox) == 1
assert mailoutbox[0].to == [user.email]
body = mailoutbox[0].body
assert body.find("http://") > 0
# Extract URL for `password_reset_from_key` view and access it
url = body[body.find("/accounts/password/reset/") :].split()[0]
resp = client.get(url)
# Follow the redirect the actual password reset page with the key
# hidden.
url = resp.url
resp = client.get(url)
assertTemplateUsed(
resp,
"account/password_reset_from_key.%s" % app_settings.TEMPLATE_EXTENSION,
)
assert "token_fail" not in resp.context_data
# Reset the password
resp = client.post(url, {"password1": "newpass123", "password2": "newpass123"})
assertRedirects(resp, reverse("account_reset_password_from_key_done"))
assert "Your password has been reset" in mailoutbox[-1].body
# Check the new password is in effect
user = get_user_model().objects.get(pk=user.pk)
assert user.check_password("newpass123")
# Trying to reset the password against the same URL (or any other
# invalid/obsolete URL) returns a bad token response
resp = client.post(url, {"password1": "newpass123", "password2": "newpass123"})
assertTemplateUsed(
resp,
"account/password_reset_from_key.%s" % app_settings.TEMPLATE_EXTENSION,
)
assert resp.context_data["token_fail"]
# Same should happen when accessing the page directly
response = client.get(url)
assertTemplateUsed(
response,
"account/password_reset_from_key.%s" % app_settings.TEMPLATE_EXTENSION,
)
assert response.context_data["token_fail"]
# When in XHR views, it should respond with a 400 bad request
# code, and the response body should contain the JSON-encoded
# error from the adapter
response = client.post(
url,
{"password1": "newpass123", "password2": "newpass123"},
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
assert response.status_code == 400
data = json.loads(response.content.decode("utf8"))
assert "invalid" in data["form"]["errors"][0]
@pytest.mark.parametrize(
"next_url,expected_location",
[(None, reverse_lazy("account_reset_password_from_key_done")), ("/foo", "/foo")],
)
def test_reset_password_from_key_next_url(
user, client, password_factory, next_url, expected_location, password_reset_url
):
url = password_reset_url(user)
query = ""
if next_url:
query = "?" + urlencode({"next": next_url})
resp = client.get(url + query)
assert resp.status_code == 302
assert (
resp["location"]
== reverse(
"account_reset_password_from_key",
kwargs={"uidb36": user_pk_to_url_str(user), "key": "set-password"},
)
+ query
)
password = password_factory()
data = {"password1": password, "password2": password}
if next_url:
data["next"] = next_url
resp = client.post(resp["location"], data)
assert resp.status_code == 302
assert resp["location"] == expected_location

View File

@@ -0,0 +1,41 @@
from allauth.account.forms import ResetPasswordForm
def test_user_email_unicode_collision(settings, rf, user_factory, mailoutbox):
settings.ACCOUNT_PREVENT_ENUMERATION = False
user_factory(username="mike123", email="mike@example.org")
user_factory(username="mike456", email="mıke@example.org")
data = {"email": "mıke@example.org"}
form = ResetPasswordForm(data)
assert form.is_valid()
form.save(rf.get("/"))
assert len(mailoutbox) == 1
assert mailoutbox[0].to == ["mıke@example.org"]
def test_user_email_domain_unicode_collision(settings, rf, user_factory, mailoutbox):
settings.ACCOUNT_PREVENT_ENUMERATION = False
user_factory(username="mike123", email="mike@ixample.org")
user_factory(username="mike456", email="mike@ıxample.org")
data = {"email": "mike@ıxample.org"}
form = ResetPasswordForm(data)
assert form.is_valid()
form.save(rf.get("/"))
assert len(mailoutbox) == 1
assert mailoutbox[0].to == ["mike@ıxample.org"]
def test_user_email_unicode_collision_nonexistent(settings, user_factory):
settings.ACCOUNT_PREVENT_ENUMERATION = False
user_factory(username="mike123", email="mike@example.org")
data = {"email": "mıke@example.org"}
form = ResetPasswordForm(data)
assert not form.is_valid()
def test_user_email_domain_unicode_collision_nonexistent(settings, user_factory):
settings.ACCOUNT_PREVENT_ENUMERATION = False
user_factory(username="mike123", email="mike@ixample.org")
data = {"email": "mike@ıxample.org"}
form = ResetPasswordForm(data)
assert not form.is_valid()

View File

@@ -0,0 +1,443 @@
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.contrib.messages.middleware import MessageMiddleware
from django.contrib.sessions.middleware import SessionMiddleware
from django.core import mail
from django.test.client import Client, RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
import pytest
from pytest_django.asserts import assertTemplateUsed
from allauth.account import app_settings
from allauth.account.adapter import get_adapter
from allauth.account.forms import BaseSignupForm, SignupForm
from allauth.account.models import EmailAddress
from allauth.core import context
from allauth.tests import TestCase
from allauth.utils import get_username_max_length
class CustomSignupFormTests(TestCase):
@override_settings(
ACCOUNT_SIGNUP_EMAIL_ENTER_TWICE=True,
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE=True,
)
def test_custom_form_field_order(self):
expected_field_order = [
"email",
"email2",
"password1",
"password2",
"username",
"last_name",
"first_name",
]
class TestSignupForm(forms.Form):
first_name = forms.CharField(max_length=30)
last_name = forms.CharField(max_length=30)
field_order = expected_field_order
class CustomSignupForm(SignupForm, TestSignupForm):
# ACCOUNT_SIGNUP_FORM_CLASS is only abided by when the
# BaseSignupForm definition is loaded the first time on Django
# startup. @override_settings() has therefore no effect.
pass
form = CustomSignupForm()
self.assertEqual(list(form.fields.keys()), expected_field_order)
def test_user_class_attribute(self):
from django.contrib.auth import get_user_model
from django.db.models.query_utils import DeferredAttribute
class CustomSignupForm(SignupForm):
# ACCOUNT_SIGNUP_FORM_CLASS is only abided by when the
# BaseSignupForm definition is loaded the first time on Django
# startup. @override_settings() has therefore no effect.
pass
User = get_user_model()
data = {
"username": "username",
"email": "user@example.com",
"password1": "very-secret",
"password2": "very-secret",
}
form = CustomSignupForm(data, email_required=True)
assert isinstance(User.username, DeferredAttribute)
form.is_valid()
assert isinstance(User.username, DeferredAttribute)
class BaseSignupFormTests(TestCase):
@override_settings(
ACCOUNT_USERNAME_REQUIRED=True, ACCOUNT_USERNAME_BLACKLIST=["username"]
)
def test_username_in_blacklist(self):
data = {
"username": "username",
"email": "user@example.com",
}
form = BaseSignupForm(data, email_required=True)
self.assertFalse(form.is_valid())
@override_settings(
ACCOUNT_USERNAME_REQUIRED=True, ACCOUNT_USERNAME_BLACKLIST=["username"]
)
def test_username_not_in_blacklist(self):
data = {
"username": "theusername",
"email": "user@example.com",
}
form = BaseSignupForm(data, email_required=True)
self.assertTrue(form.is_valid())
@override_settings(ACCOUNT_USERNAME_REQUIRED=True)
def test_username_maxlength(self):
data = {
"username": "username",
"email": "user@example.com",
}
form = BaseSignupForm(data, email_required=True)
max_length = get_username_max_length()
field = form.fields["username"]
self.assertEqual(field.max_length, max_length)
widget = field.widget
self.assertEqual(widget.attrs.get("maxlength"), str(max_length))
def test_signup_email_verification(settings, db):
settings.ACCOUNT_USERNAME_REQUIRED = True
settings.ACCOUNT_SIGNUP_EMAIL_ENTER_TWICE = True
data = {
"username": "username",
"email": "user@example.com",
}
form = BaseSignupForm(data, email_required=True)
assert not form.is_valid()
data = {
"username": "username",
"email": "user@example.com",
"email2": "USER@example.COM",
}
form = BaseSignupForm(data, email_required=True)
assert form.is_valid()
data["email2"] = "anotheruser@example.com"
form = BaseSignupForm(data, email_required=True)
assert not form.is_valid()
@override_settings(
ACCOUNT_DEFAULT_HTTP_PROTOCOL="https",
ACCOUNT_EMAIL_VERIFICATION=app_settings.EmailVerificationMethod.MANDATORY,
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.USERNAME,
ACCOUNT_SIGNUP_FORM_CLASS=None,
ACCOUNT_EMAIL_SUBJECT_PREFIX=None,
LOGIN_REDIRECT_URL="/accounts/profile/",
ACCOUNT_SIGNUP_REDIRECT_URL="/accounts/welcome/",
ACCOUNT_ADAPTER="allauth.account.adapter.DefaultAccountAdapter",
ACCOUNT_USERNAME_REQUIRED=True,
)
class SignupTests(TestCase):
def test_signup_same_email_verified_externally(self):
user = self._test_signup_email_verified_externally(
"john@example.com", "john@example.com"
)
self.assertEqual(EmailAddress.objects.filter(user=user).count(), 1)
EmailAddress.objects.get(
verified=True, email="john@example.com", user=user, primary=True
)
def test_signup_other_email_verified_externally(self):
"""
John is invited on john@example.org, but signs up via john@example.com.
Email verification is by-passed, their home email address is
used as a secondary.
"""
user = self._test_signup_email_verified_externally(
"john@example.com", "john@example.org"
)
self.assertEqual(EmailAddress.objects.filter(user=user).count(), 2)
EmailAddress.objects.get(
verified=False, email="john@example.com", user=user, primary=False
)
EmailAddress.objects.get(
verified=True, email="john@example.org", user=user, primary=True
)
def _test_signup_email_verified_externally(self, signup_email, verified_email):
username = "johndoe"
request = RequestFactory().post(
reverse("account_signup"),
{
"username": username,
"email": signup_email,
"password1": "johndoe",
"password2": "johndoe",
},
)
# Fake stash_verified_email
SessionMiddleware(lambda request: None).process_request(request)
MessageMiddleware(lambda request: None).process_request(request)
request.user = AnonymousUser()
request.session["account_verified_email"] = verified_email
from allauth.account.views import signup
with context.request_context(request):
resp = signup(request)
self.assertEqual(resp.status_code, 302)
self.assertEqual(
resp["location"], get_adapter().get_signup_redirect_url(request)
)
self.assertEqual(len(mail.outbox), 0)
return get_user_model().objects.get(username=username)
@override_settings(
ACCOUNT_USERNAME_REQUIRED=True,
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE=True,
)
def test_signup_password_twice_form_error(self):
resp = self.client.post(
reverse("account_signup"),
data={
"username": "johndoe",
"email": "john@example.org",
"password1": "johndoe",
"password2": "janedoe",
},
)
self.assertFormError(
resp.context["form"],
"password2",
"You must type the same password each time.",
)
@override_settings(
ACCOUNT_USERNAME_REQUIRED=True, ACCOUNT_SIGNUP_EMAIL_ENTER_TWICE=True
)
def test_signup_email_twice(self):
request = RequestFactory().post(
reverse("account_signup"),
{
"username": "johndoe",
"email": "john@example.org",
"email2": "john@example.org",
"password1": "johndoe",
"password2": "johndoe",
},
)
SessionMiddleware(lambda request: None).process_request(request)
MessageMiddleware(lambda request: None).process_request(request)
request.user = AnonymousUser()
from allauth.account.views import signup
with context.request_context(request):
signup(request)
user = get_user_model().objects.get(username="johndoe")
self.assertEqual(user.email, "john@example.org")
@override_settings(
AUTH_PASSWORD_VALIDATORS=[
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
"OPTIONS": {
"min_length": 9,
},
}
]
)
def test_django_password_validation(self):
resp = self.client.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": "john@example.com",
"password1": "johndoe",
"password2": "johndoe",
},
)
self.assertFormError(resp.context["form"], None, [])
self.assertFormError(
resp.context["form"],
"password1",
["This password is too short. It must contain at least 9 characters."],
)
def test_prevent_enumeration_with_mandatory_verification(
settings, user_factory, email_factory
):
settings.ACCOUNT_PREVENT_ENUMERATION = True
settings.ACCOUNT_AUTHENTICATION_METHOD = app_settings.AuthenticationMethod.EMAIL
settings.ACCOUNT_EMAIL_VERIFICATION = app_settings.EmailVerificationMethod.MANDATORY
user = user_factory(username="john", email="john@example.org", password="doe")
c = Client()
resp = c.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": email_factory(email=user.email, mixed_case=True),
"password1": "johndoe",
"password2": "johndoe",
},
)
assert resp.status_code == 302
assert resp["location"] == reverse("account_email_verification_sent")
assertTemplateUsed(resp, "account/email/account_already_exists_message.txt")
assertTemplateUsed(resp, "account/messages/email_confirmation_sent.txt")
assert EmailAddress.objects.filter(email="john@example.org").count() == 1
def test_prevent_enumeration_off(settings, user_factory, email_factory):
settings.ACCOUNT_PREVENT_ENUMERATION = False
settings.ACCOUNT_AUTHENTICATION_METHOD = app_settings.AuthenticationMethod.EMAIL
settings.ACCOUNT_EMAIL_VERIFICATION = app_settings.EmailVerificationMethod.MANDATORY
user = user_factory(username="john", email="john@example.org", password="doe")
c = Client()
resp = c.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": email_factory(email=user.email, mixed_case=True),
"password1": "johndoe",
"password2": "johndoe",
},
)
assert resp.status_code == 200
assert resp.context["form"].errors == {
"email": ["A user is already registered with this email address."]
}
def test_prevent_enumeration_strictly(settings, user_factory, email_factory):
settings.ACCOUNT_PREVENT_ENUMERATION = "strict"
settings.ACCOUNT_AUTHENTICATION_METHOD = app_settings.AuthenticationMethod.EMAIL
settings.ACCOUNT_EMAIL_VERIFICATION = app_settings.EmailVerificationMethod.NONE
user = user_factory(username="john", email="john@example.org", password="doe")
c = Client()
resp = c.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": email_factory(email=user.email, mixed_case=True),
"password1": "johndoe",
"password2": "johndoe",
},
)
assert resp.status_code == 302
assert resp["location"] == settings.LOGIN_REDIRECT_URL
assert EmailAddress.objects.filter(email="john@example.org").count() == 2
def test_prevent_enumeration_on(settings, user_factory, email_factory):
settings.ACCOUNT_PREVENT_ENUMERATION = True
settings.ACCOUNT_AUTHENTICATION_METHOD = app_settings.AuthenticationMethod.EMAIL
settings.ACCOUNT_EMAIL_VERIFICATION = app_settings.EmailVerificationMethod.NONE
user = user_factory(username="john", email="john@example.org", password="doe")
c = Client()
resp = c.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": email_factory(email=user.email, mixed_case=True),
"password1": "johndoe",
"password2": "johndoe",
},
)
assert resp.status_code == 200
assert resp.context["form"].errors == {
"email": ["A user is already registered with this email address."]
}
@pytest.mark.django_db
def test_get_initial_with_valid_email():
"""Test that the email field is populated with a valid email."""
request = RequestFactory().get("/signup/?email=test@example.com")
from allauth.account.views import signup
SessionMiddleware(lambda request: None).process_request(request)
request.user = AnonymousUser()
with context.request_context(request):
view = signup(request)
assert view.context_data["view"].get_initial()["email"] == "test@example.com"
def test_signup_user_model_no_email(settings, client, password_factory, db, mailoutbox):
settings.ACCOUNT_USERNAME_REQUIRED = False
settings.ACCOUNT_EMAIL_REQUIRED = True
settings.ACCOUNT_EMAIL_VERIFICATION = app_settings.EmailVerificationMethod.MANDATORY
settings.ACCOUNT_USER_MODEL_EMAIL_FIELD = None
password = password_factory()
email = "user@example.com"
resp = client.post(
reverse("account_signup"),
{
"email": email,
"password1": password,
"password2": password,
},
)
assert resp.status_code == 302
email = EmailAddress.objects.get(email=email)
assert email.primary
assert not email.verified
assert len(mailoutbox) == 1
def test_email_lower_case(db, settings):
settings.ACCOUNT_AUTHENTICATION_METHOD = app_settings.AuthenticationMethod.EMAIL
settings.ACCOUNT_EMAIL_VERIFICATION = app_settings.EmailVerificationMethod.NONE
c = Client()
resp = c.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": "JoHn@DoE.oRg",
"password1": "johndoe",
"password2": "johndoe",
},
)
assert resp.status_code == 302
assert EmailAddress.objects.filter(email="john@doe.org").count() == 1
def test_does_not_create_user_when_honeypot_filled_out(client, db, settings):
settings.ACCOUNT_SIGNUP_FORM_HONEYPOT_FIELD = "phone_number"
resp = client.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": "john@example.com",
"password1": "Password1@",
"password2": "Password1@",
"phone_number": "5551231234",
},
)
assert not get_user_model().objects.all().exists()
assert resp.status_code == 302
def test_create_user_when_honeypot_not_filled_out(client, db, settings):
settings.ACCOUNT_SIGNUP_FORM_HONEYPOT_FIELD = "phone_number"
resp = client.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": "john@example.com",
"password1": "Password1@",
"password2": "Password1@",
"phone_number": "",
},
)
assert get_user_model().objects.filter(username="johndoe").count() == 1
assert resp.status_code == 302

View File

@@ -0,0 +1,135 @@
import uuid
from unittest.mock import patch
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.messages.api import get_messages
from django.contrib.messages.middleware import MessageMiddleware
from django.contrib.sessions.middleware import SessionMiddleware
from django.core import mail, validators
from django.core.exceptions import ValidationError
from django.template import Context, Template
from django.test.client import RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
import allauth.app_settings
from allauth.account.adapter import get_adapter
from allauth.account.models import EmailAddress
from allauth.account.utils import (
filter_users_by_username,
url_str_to_user_pk,
user_pk_to_url_str,
user_username,
)
from allauth.core import context
from allauth.tests import TestCase
from .test_models import UUIDUser
test_username_validators = [
validators.RegexValidator(regex=r"^[a-c]+$", message="not abc")
]
class UtilsTests(TestCase):
def setUp(self):
self.user_id = uuid.uuid4().hex
def test_url_str_to_pk_identifies_UUID_as_stringlike(self):
with patch("allauth.account.utils.get_user_model") as mocked_gum:
mocked_gum.return_value = UUIDUser
self.assertEqual(url_str_to_user_pk(self.user_id), uuid.UUID(self.user_id))
def test_pk_to_url_string_identifies_UUID_as_stringlike(self):
with patch("allauth.account.utils.get_user_model") as mocked_gum:
mocked_gum.return_value = UUIDUser
user = UUIDUser(is_active=True, email="john@example.com", username="john")
self.assertEqual(user_pk_to_url_str(user), user.pk.hex)
@override_settings(ACCOUNT_PRESERVE_USERNAME_CASING=False)
def test_username_lower_cased(self):
user = get_user_model()()
user_username(user, "CamelCase")
self.assertEqual(user_username(user), "camelcase")
# TODO: Actually test something
filter_users_by_username("CamelCase", "FooBar")
@override_settings(ACCOUNT_PRESERVE_USERNAME_CASING=True)
def test_username_case_preserved(self):
user = get_user_model()()
user_username(user, "CamelCase")
self.assertEqual(user_username(user), "CamelCase")
# TODO: Actually test something
filter_users_by_username("camelcase", "foobar")
def test_user_display(self):
user = get_user_model()(username="john<br/>doe")
expected_name = "john&lt;br/&gt;doe"
templates = [
"{% load account %}{% user_display user %}",
"{% load account %}{% user_display user as x %}{{ x }}",
]
for template in templates:
t = Template(template)
content = t.render(Context({"user": user}))
self.assertEqual(content, expected_name)
def test_message_escaping(self):
request = RequestFactory().get("/")
SessionMiddleware(lambda request: None).process_request(request)
MessageMiddleware(lambda request: None).process_request(request)
user = get_user_model()()
user_username(user, "'<8")
context = {"user": user}
get_adapter().add_message(
request, messages.SUCCESS, "account/messages/logged_in.txt", context
)
msgs = get_messages(request)
actual_message = msgs._queued_messages[0].message
assert user.username in actual_message, actual_message
def test_email_escaping(self):
site_name = "testserver"
if allauth.app_settings.SITES_ENABLED:
from django.contrib.sites.models import Site
site = Site.objects.get_current()
site.name = site_name = '<enc&"test>'
site.save()
u = get_user_model().objects.create(username="test", email="user@example.com")
request = RequestFactory().get("/")
EmailAddress.objects.add_email(request, u, u.email, confirm=True)
self.assertTrue(mail.outbox[0].subject[1:].startswith(site_name))
@override_settings(
ACCOUNT_USERNAME_VALIDATORS="allauth.account.tests.test_utils.test_username_validators"
)
def test_username_validator(self):
get_adapter().clean_username("abc")
self.assertRaises(ValidationError, lambda: get_adapter().clean_username("def"))
@override_settings(ALLOWED_HOSTS=["allowed_host", "testserver"])
def test_is_safe_url_no_wildcard(self):
with context.request_context(RequestFactory().get("/")):
self.assertTrue(get_adapter().is_safe_url("http://allowed_host/"))
self.assertFalse(get_adapter().is_safe_url("http://other_host/"))
@override_settings(ALLOWED_HOSTS=["*"])
def test_is_safe_url_wildcard(self):
with context.request_context(RequestFactory().get("/")):
self.assertTrue(get_adapter().is_safe_url("http://foobar.com/"))
self.assertTrue(get_adapter().is_safe_url("http://other_host/"))
@override_settings(ALLOWED_HOSTS=["allowed_host", "testserver"])
def test_is_safe_url_relative_path(self):
with context.request_context(RequestFactory().get("/")):
self.assertTrue(get_adapter().is_safe_url("/foo/bar"))
def test_redirect_noreversematch(auth_client):
# We used to call `django.shortcuts.redirect()` as is, but that one throws a
# `NoReverseMatch`, resulting in 500s.
resp = auth_client.post(reverse("account_logout") + "?next=badurlname")
assert resp["location"] == "/badurlname"

View File

@@ -0,0 +1,85 @@
from django.conf import settings
from django.urls import path, re_path
from allauth import app_settings as allauth_app_settings
from allauth.account import app_settings
from . import views
urlpatterns = [
path("login/", views.login, name="account_login"),
path("logout/", views.logout, name="account_logout"),
path("inactive/", views.account_inactive, name="account_inactive"),
]
if not allauth_app_settings.SOCIALACCOUNT_ONLY:
urlpatterns.extend(
[
path("signup/", views.signup, name="account_signup"),
path(
"reauthenticate/", views.reauthenticate, name="account_reauthenticate"
),
# Email
path("email/", views.email, name="account_email"),
path(
"confirm-email/",
views.email_verification_sent,
name="account_email_verification_sent",
),
re_path(
r"^confirm-email/(?P<key>[-:\w]+)/$",
views.confirm_email,
name="account_confirm_email",
),
path(
"password/change/",
views.password_change,
name="account_change_password",
),
path("password/set/", views.password_set, name="account_set_password"),
# password reset
path(
"password/reset/", views.password_reset, name="account_reset_password"
),
path(
"password/reset/done/",
views.password_reset_done,
name="account_reset_password_done",
),
re_path(
r"^password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$",
views.password_reset_from_key,
name="account_reset_password_from_key",
),
path(
"password/reset/key/done/",
views.password_reset_from_key_done,
name="account_reset_password_from_key_done",
),
path(
"login/code/confirm/",
views.confirm_login_code,
name="account_confirm_login_code",
),
]
)
if getattr(settings, "MFA_PASSKEY_SIGNUP_ENABLED", False):
urlpatterns.append(
path(
"signup/passkey/",
views.signup_by_passkey,
name="account_signup_by_passkey",
)
)
if app_settings.LOGIN_BY_CODE_ENABLED:
urlpatterns.extend(
[
path(
"login/code/",
views.request_login_code,
name="account_request_login_code",
),
]
)

View File

@@ -0,0 +1,406 @@
import unicodedata
from collections import OrderedDict
from typing import Optional
from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model
from django.core.exceptions import FieldDoesNotExist
from django.db import models
from django.db.models import Q
from django.utils.encoding import force_str
from django.utils.http import base36_to_int, int_to_base36
from allauth.account import app_settings
from allauth.account.adapter import get_adapter
from allauth.account.internal import flows
from allauth.account.models import Login
from allauth.core.internal import httpkit
from allauth.utils import (
get_request_param,
import_callable,
valid_email_or_none,
)
def _unicode_ci_compare(s1, s2) -> bool:
"""
Perform case-insensitive comparison of two identifiers, using the
recommended algorithm from Unicode Technical Report 36, section
2.11.2(B)(2).
"""
norm_s1 = unicodedata.normalize("NFKC", s1).casefold()
norm_s2 = unicodedata.normalize("NFKC", s2).casefold()
return norm_s1 == norm_s2
def get_next_redirect_url(
request, redirect_field_name=REDIRECT_FIELD_NAME
) -> Optional[str]:
"""
Returns the next URL to redirect to, if it was explicitly passed
via the request.
"""
redirect_to = get_request_param(request, redirect_field_name)
if redirect_to and not get_adapter().is_safe_url(redirect_to):
redirect_to = None
return redirect_to
def get_login_redirect_url(
request, url=None, redirect_field_name=REDIRECT_FIELD_NAME, signup=False
) -> str:
ret = url
if url and callable(url):
# In order to be able to pass url getters around that depend
# on e.g. the authenticated state.
ret = url()
if not ret:
ret = get_next_redirect_url(request, redirect_field_name=redirect_field_name)
if not ret:
if signup:
ret = get_adapter().get_signup_redirect_url(request)
else:
ret = get_adapter().get_login_redirect_url(request)
return ret
_user_display_callable = None
def default_user_display(user) -> str:
ret = ""
if app_settings.USER_MODEL_USERNAME_FIELD:
ret = getattr(user, app_settings.USER_MODEL_USERNAME_FIELD)
return ret or force_str(user) or user._meta.verbose_name
def user_display(user) -> str:
global _user_display_callable
if not _user_display_callable:
f = getattr(settings, "ACCOUNT_USER_DISPLAY", default_user_display)
_user_display_callable = import_callable(f)
return _user_display_callable(user)
def user_field(user, field, *args, commit=False):
"""
Gets or sets (optional) user model fields. No-op if fields do not exist.
"""
if not field:
return
User = get_user_model()
try:
field_meta = User._meta.get_field(field)
max_length = field_meta.max_length
except FieldDoesNotExist:
if not hasattr(user, field):
return
max_length = None
if args:
# Setter
v = args[0]
if v:
v = v[0:max_length]
setattr(user, field, v)
if commit:
user.save(update_fields=[field])
else:
# Getter
return getattr(user, field)
def user_username(user, *args, commit=False):
if args and not app_settings.PRESERVE_USERNAME_CASING and args[0]:
args = [args[0].lower()]
return user_field(user, app_settings.USER_MODEL_USERNAME_FIELD, *args)
def user_email(user, *args, commit=False):
if args and args[0]:
args = [args[0].lower()]
ret = user_field(user, app_settings.USER_MODEL_EMAIL_FIELD, *args, commit=commit)
if ret:
ret = ret.lower()
return ret
def has_verified_email(user, email=None) -> bool:
from .models import EmailAddress
emailaddress = None
if email:
ret = False
try:
emailaddress = EmailAddress.objects.get_for_user(user, email)
ret = emailaddress.verified
except EmailAddress.DoesNotExist:
pass
else:
ret = EmailAddress.objects.filter(user=user, verified=True).exists()
return ret
def perform_login(
request,
user,
email_verification=None,
redirect_url=None,
signal_kwargs=None,
signup=False,
email=None,
):
login = Login(
user=user,
email_verification=email_verification,
redirect_url=redirect_url,
signal_kwargs=signal_kwargs,
signup=signup,
email=email,
)
return flows.login.perform_login(request, login)
def complete_signup(request, user, email_verification, success_url, signal_kwargs=None):
return flows.signup.complete_signup(
request,
user=user,
email_verification=email_verification,
redirect_url=success_url,
signal_kwargs=signal_kwargs,
)
def cleanup_email_addresses(request, addresses):
"""
Takes a list of EmailAddress instances and cleans it up, making
sure only valid ones remain, without multiple primaries etc.
Order is important: e.g. if multiple primary email addresses
exist, the first one encountered will be kept as primary.
"""
from .models import EmailAddress
adapter = get_adapter()
# Let's group by `email`
e2a = OrderedDict() # maps email to EmailAddress
primary_addresses = []
verified_addresses = []
primary_verified_addresses = []
for address in addresses:
# Pick up only valid ones...
email = valid_email_or_none(address.email)
if not email:
continue
address.email = email # `valid_email_or_none` lower cases
# ... and non-conflicting ones...
if (
app_settings.UNIQUE_EMAIL
and app_settings.PREVENT_ENUMERATION != "strict"
and EmailAddress.objects.lookup([email])
):
# Email address already exists.
continue
if (
app_settings.UNIQUE_EMAIL
and app_settings.PREVENT_ENUMERATION == "strict"
and address.verified
and EmailAddress.objects.is_verified(email)
):
# Email address already exists, and is verified as well.
continue
a = e2a.get(email)
if a:
a.primary = a.primary or address.primary
a.verified = a.verified or address.verified
else:
a = address
a.verified = a.verified or adapter.is_email_verified(request, a.email)
e2a[email] = a
if a.primary:
primary_addresses.append(a)
if a.verified:
primary_verified_addresses.append(a)
if a.verified:
verified_addresses.append(a)
# Now that we got things sorted out, let's assign a primary
if primary_verified_addresses:
primary_address = primary_verified_addresses[0]
elif verified_addresses:
# Pick any verified as primary
primary_address = verified_addresses[0]
elif primary_addresses:
# Okay, let's pick primary then, even if unverified
primary_address = primary_addresses[0]
elif e2a:
# Pick the first
primary_address = list(e2a.values())[0]
else:
# Empty
primary_address = None
# There can only be one primary
for a in e2a.values():
a.primary = primary_address.email.lower() == a.email.lower()
return list(e2a.values()), primary_address
def setup_user_email(request, user, addresses):
"""
Creates proper EmailAddress for the user that was just signed
up. Only sets up, doesn't do any other handling such as sending
out email confirmation mails etc.
"""
from .models import EmailAddress
assert not EmailAddress.objects.filter(user=user).exists()
priority_addresses = []
# Is there a stashed email?
adapter = get_adapter()
stashed_email = adapter.unstash_verified_email(request)
if stashed_email:
priority_addresses.append(
EmailAddress(
user=user, email=stashed_email.lower(), primary=True, verified=True
)
)
email = user_email(user)
if email:
priority_addresses.append(
EmailAddress(user=user, email=email.lower(), primary=True, verified=False)
)
addresses, primary = cleanup_email_addresses(
request, priority_addresses + addresses
)
for a in addresses:
a.user = user
a.save()
EmailAddress.objects.fill_cache_for_user(user, addresses)
if primary and (email or "").lower() != primary.email.lower():
user_email(user, primary.email)
user.save()
return primary
def send_email_confirmation(request, user, signup=False, email=None) -> bool:
return flows.email_verification.send_verification_email(
request, user, signup=signup, email=email
)
def sync_user_email_addresses(user):
"""
Keep user.email in sync with user.emailaddress_set.
Under some circumstances the user.email may not have ended up as
an EmailAddress record, e.g. in the case of manually created admin
users.
"""
from .models import EmailAddress
email = user_email(user)
if email and not EmailAddress.objects.filter(user=user, email=email).exists():
# get_or_create() to gracefully handle races
EmailAddress.objects.get_or_create(
user=user, email=email, defaults={"primary": False, "verified": False}
)
def filter_users_by_username(*username):
if app_settings.PRESERVE_USERNAME_CASING:
qlist = [
Q(**{app_settings.USER_MODEL_USERNAME_FIELD + "__iexact": u})
for u in username
]
q = qlist[0]
for q2 in qlist[1:]:
q = q | q2
ret = get_user_model()._default_manager.filter(q)
else:
ret = get_user_model()._default_manager.filter(
**{
app_settings.USER_MODEL_USERNAME_FIELD
+ "__in": [u.lower() for u in username]
}
)
return ret
def filter_users_by_email(
email: str, is_active: Optional[bool] = None, prefer_verified: bool = False
):
"""Return list of users by email address
Typically one, at most just a few in length. First we look through
EmailAddress table, than customisable User model table. Add results
together avoiding SQL joins and deduplicate.
`prefer_verified`: When looking up users by email, there can be cases where
users with verified email addresses are preferable above users who did not
verify their email address. The password reset is such a use case -- if
there is a user with a verified email than that user should be returned, not
one of the other users.
"""
from .models import EmailAddress
User = get_user_model()
email = email.lower()
mails = list(EmailAddress.objects.filter(email=email).select_related("user"))
is_verified = False
if prefer_verified:
verified_mails = list(filter(lambda e: e.verified, mails))
if verified_mails:
mails = verified_mails
is_verified = True
users = []
for e in mails:
if _unicode_ci_compare(e.email, email):
users.append(e.user)
if app_settings.USER_MODEL_EMAIL_FIELD and not is_verified:
q_dict = {app_settings.USER_MODEL_EMAIL_FIELD: email}
user_qs = User.objects.filter(**q_dict)
for user in user_qs.iterator():
user_email = getattr(user, app_settings.USER_MODEL_EMAIL_FIELD)
if _unicode_ci_compare(user_email, email):
users.append(user)
if is_active is not None:
users = [u for u in set(users) if u.is_active == is_active]
return list(set(users))
def passthrough_next_redirect_url(request, url, redirect_field_name):
next_url = get_next_redirect_url(request, redirect_field_name)
if next_url:
url = httpkit.add_query_params(url, {redirect_field_name: next_url})
return url
def user_pk_to_url_str(user) -> str:
"""
This should return a string.
"""
User = get_user_model()
pk_field_class = type(User._meta.pk)
if issubclass(pk_field_class, models.UUIDField):
if isinstance(user.pk, str):
return user.pk
return user.pk.hex
elif issubclass(pk_field_class, models.IntegerField):
return int_to_base36(int(user.pk))
return str(user.pk)
def url_str_to_user_pk(pk_str):
User = get_user_model()
remote_field = getattr(User._meta.pk, "remote_field", None)
if remote_field and getattr(remote_field, "to", None):
pk_field = User._meta.pk.remote_field.to._meta.pk
else:
pk_field = User._meta.pk
pk_field_class = type(pk_field)
if issubclass(pk_field_class, models.IntegerField):
pk = base36_to_int(pk_str)
# always call to_python() -- because there are fields like HashidField
# that derive from IntegerField.
pk = pk_field.to_python(pk)
else:
pk = pk_field.to_python(pk_str)
return pk

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
from django.apps import apps
class AppSettings:
def __init__(self, prefix):
self.prefix = prefix
def _setting(self, name, dflt):
from allauth.utils import get_setting
return get_setting(self.prefix + name, dflt)
@property
def SITES_ENABLED(self):
return apps.is_installed("django.contrib.sites")
@property
def SOCIALACCOUNT_ENABLED(self):
return apps.is_installed("allauth.socialaccount")
@property
def SOCIALACCOUNT_ONLY(self) -> bool:
from allauth.utils import get_setting
return get_setting("SOCIALACCOUNT_ONLY", False)
@property
def MFA_ENABLED(self):
return apps.is_installed("allauth.mfa")
@property
def USERSESSIONS_ENABLED(self):
return apps.is_installed("allauth.usersessions")
@property
def HEADLESS_ENABLED(self):
return apps.is_installed("allauth.headless")
@property
def HEADLESS_ONLY(self) -> bool:
from allauth.utils import get_setting
return get_setting("HEADLESS_ONLY", False)
@property
def DEFAULT_AUTO_FIELD(self):
return self._setting("DEFAULT_AUTO_FIELD", None)
_app_settings = AppSettings("ALLAUTH_")
def __getattr__(name):
# See https://peps.python.org/pep-0562/
return getattr(_app_settings, name)

View File

@@ -0,0 +1,324 @@
import json
import random
import time
import uuid
from contextlib import contextmanager
from unittest.mock import Mock, PropertyMock, patch
from django.contrib.auth import get_user_model
from django.contrib.messages.middleware import MessageMiddleware
from django.contrib.sessions.middleware import SessionMiddleware
import pytest
from allauth.account.models import EmailAddress
from allauth.account.utils import user_email, user_pk_to_url_str, user_username
from allauth.core import context
from allauth.socialaccount.internal import statekit
from allauth.socialaccount.providers.base.constants import AuthProcess
def pytest_collection_modifyitems(config, items):
if config.getoption("--ds") == "tests.headless_only.settings":
removed_items = []
for item in items:
if not item.location[0].startswith("allauth/headless"):
removed_items.append(item)
for item in removed_items:
items.remove(item)
@pytest.fixture
def user(user_factory):
return user_factory()
@pytest.fixture
def auth_client(client, user):
client.force_login(user)
return client
@pytest.fixture
def password_factory():
def f():
return str(uuid.uuid4())
return f
@pytest.fixture
def user_password(password_factory):
return password_factory()
@pytest.fixture
def email_verified():
return True
@pytest.fixture
def user_factory(email_factory, db, user_password, email_verified):
def factory(
email=None,
username=None,
commit=True,
with_email=True,
email_verified=email_verified,
password=None,
with_emailaddress=True,
with_totp=False,
):
if not username:
username = uuid.uuid4().hex
if not email and with_email:
email = email_factory(username=username)
User = get_user_model()
user = User()
if password == "!":
user.password = password
else:
user.set_password(user_password if password is None else password)
user_username(user, username)
user_email(user, email or "")
if commit:
user.save()
if email and with_emailaddress:
EmailAddress.objects.create(
user=user,
email=email.lower(),
verified=email_verified,
primary=True,
)
if with_totp:
from allauth.mfa.totp.internal import auth
auth.TOTP.activate(user, auth.generate_totp_secret())
return user
return factory
@pytest.fixture
def email_factory():
def factory(username=None, email=None, mixed_case=False):
if email is None:
if not username:
username = uuid.uuid4().hex
email = f"{username}@{uuid.uuid4().hex}.org"
if mixed_case:
email = "".join([random.choice([c.upper(), c.lower()]) for c in email])
else:
email = email.lower()
return email
return factory
@pytest.fixture
def reauthentication_bypass():
@contextmanager
def f():
with patch(
"allauth.account.internal.flows.reauthentication.did_recently_authenticate"
) as m:
m.return_value = True
yield
return f
@pytest.fixture
def webauthn_authentication_bypass():
@contextmanager
def f(authenticator):
from fido2.utils import websafe_encode
from allauth.mfa.adapter import get_adapter
with patch(
"allauth.mfa.webauthn.internal.auth.WebAuthn.authenticator_data",
new_callable=PropertyMock,
) as ad_m:
with patch("fido2.server.Fido2Server.authenticate_begin") as ab_m:
ab_m.return_value = ({}, {"state": "dummy"})
with patch("fido2.server.Fido2Server.authenticate_complete") as ac_m:
with patch(
"allauth.mfa.webauthn.internal.auth.parse_authentication_response"
) as m:
user_handle = (
get_adapter().get_public_key_credential_user_entity(
authenticator.user
)["id"]
)
authenticator_data = Mock()
authenticator_data.credential_data.credential_id = (
"credential_id"
)
ad_m.return_value = authenticator_data
m.return_value = Mock()
binding = Mock()
binding.credential_id = "credential_id"
ac_m.return_value = binding
yield json.dumps(
{"response": {"userHandle": websafe_encode(user_handle)}}
)
return f
@pytest.fixture
def webauthn_registration_bypass():
@contextmanager
def f(user, passwordless):
with patch("fido2.server.Fido2Server.register_complete") as rc_m:
with patch(
"allauth.mfa.webauthn.internal.auth.parse_registration_response"
) as m:
m.return_value = Mock()
class FakeAuthenticatorData(bytes):
def is_user_verified(self):
return passwordless
binding = FakeAuthenticatorData(b"binding")
rc_m.return_value = binding
yield json.dumps(
{
"authenticatorAttachment": "cross-platform",
"clientExtensionResults": {"credProps": {"rk": passwordless}},
"id": "123",
"rawId": "456",
"response": {
"attestationObject": "ao",
"clientDataJSON": "cdj",
"transports": ["usb"],
},
"type": "public-key",
}
)
return f
@pytest.fixture(autouse=True)
def clear_context_request():
context._request_var.set(None)
@pytest.fixture
def enable_cache(settings):
from django.core.cache import cache
settings.CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
}
}
cache.clear()
yield
@pytest.fixture
def totp_validation_bypass():
@contextmanager
def f():
with patch("allauth.mfa.totp.internal.auth.validate_totp_code") as m:
m.return_value = True
yield
return f
@pytest.fixture
def provider_id():
return "unittest-server"
@pytest.fixture
def password_reset_key_generator():
def f(user):
from allauth.account import app_settings
token_generator = app_settings.PASSWORD_RESET_TOKEN_GENERATOR()
uid = user_pk_to_url_str(user)
temp_key = token_generator.make_token(user)
key = f"{uid}-{temp_key}"
return key
return f
@pytest.fixture
def google_provider_settings(settings):
gsettings = {"APPS": [{"client_id": "client_id", "secret": "secret"}]}
settings.SOCIALACCOUNT_PROVIDERS = {"google": gsettings}
return gsettings
@pytest.fixture
def user_with_totp(user):
from allauth.mfa.totp.internal import auth
auth.TOTP.activate(user, auth.generate_totp_secret())
return user
@pytest.fixture
def user_with_recovery_codes(user_with_totp):
from allauth.mfa.recovery_codes.internal import auth
auth.RecoveryCodes.activate(user_with_totp)
return user_with_totp
@pytest.fixture
def passkey(user):
from allauth.mfa.models import Authenticator
authenticator = Authenticator.objects.create(
user=user,
type=Authenticator.Type.WEBAUTHN,
data={
"name": "Test passkey",
"passwordless": True,
"credential": {},
},
)
return authenticator
@pytest.fixture
def user_with_passkey(user, passkey):
return user
@pytest.fixture
def sociallogin_setup_state():
def setup(client, process=None, next_url=None, **kwargs):
state_id = "123"
session = client.session
state = {"process": process or AuthProcess.LOGIN, **kwargs}
if next_url:
state["next"] = next_url
states = {}
states[state_id] = [state, time.time()]
session[statekit.STATES_SESSION_KEY] = states
session.save()
return state_id
return setup
@pytest.fixture
def request_factory(rf):
class RequestFactory:
def get(self, path):
request = rf.get(path)
SessionMiddleware(lambda request: None).process_request(request)
MessageMiddleware(lambda request: None).process_request(request)
return request
return RequestFactory()

View File

@@ -0,0 +1,20 @@
from contextlib import contextmanager
from contextvars import ContextVar
_request_var = ContextVar("request", default=None)
def __getattr__(name):
if name == "request":
return _request_var.get()
raise AttributeError(name)
@contextmanager
def request_context(request):
token = _request_var.set(request)
try:
yield
finally:
_request_var.reset(token)

View File

@@ -0,0 +1,25 @@
class ImmediateHttpResponse(Exception):
"""
This exception is used to interrupt the flow of processing to immediately
return a custom HttpResponse.
"""
def __init__(self, response):
self.response = response
class ReauthenticationRequired(Exception):
"""
The action could not be performed because the user needs to
reauthenticate.
"""
pass
class SignupClosedException(Exception):
"""
Throws when attemtping to signup while signup is closed.
"""
pass

View File

@@ -0,0 +1,17 @@
from django.core.exceptions import ValidationError
from allauth.core import context
class BaseAdapter:
def __init__(self, request=None):
# Explicitly passing `request` is deprecated, just use:
# `allauth.core.context.request`.
self.request = context.request
def validation_error(self, code, *args):
message = self.error_messages[code]
if args:
message = message % args
exc = ValidationError(message, code=code)
return exc

View File

@@ -0,0 +1,111 @@
import json
from urllib.parse import parse_qs, quote, urlencode, urlparse, urlunparse
from django import shortcuts
from django.core.exceptions import ImproperlyConfigured
from django.http import (
HttpResponseRedirect,
HttpResponseServerError,
QueryDict,
)
from django.urls import NoReverseMatch, reverse
from allauth import app_settings as allauth_settings
def serialize_request(request):
return json.dumps(
{
"path": request.path,
"path_info": request.path_info,
"META": {k: v for k, v in request.META.items() if isinstance(v, str)},
"GET": request.GET.urlencode(),
"POST": request.POST.urlencode(),
"method": request.method,
"scheme": request.scheme,
}
)
def deserialize_request(s, request):
data = json.loads(s)
request.GET = QueryDict(data["GET"])
request.POST = QueryDict(data["POST"])
request.META = data["META"]
request.path = data["path"]
request.path_info = data["path_info"]
request.method = data["method"]
request._get_scheme = lambda: data["scheme"]
return request
def redirect(to):
try:
return shortcuts.redirect(to)
except NoReverseMatch:
return shortcuts.redirect(f"/{to}")
def add_query_params(url, params):
parsed_url = urlparse(url)
query_params = parse_qs(parsed_url.query)
query_params.update(params)
encoded_query = urlencode(query_params, doseq=True)
new_url = urlunparse(
(
parsed_url.scheme,
parsed_url.netloc,
parsed_url.path,
parsed_url.params,
encoded_query,
parsed_url.fragment,
)
)
return new_url
def render_url(request, url_template, **kwargs):
url = url_template
for k, v in kwargs.items():
qi = url.find("?")
ki = url.find("{" + k + "}")
if ki < 0:
raise ImproperlyConfigured(url_template)
is_query_param = qi >= 0 and ki > qi
if is_query_param:
qv = urlencode({"k": v}).partition("k=")[2]
else:
qv = quote(v)
url = url.replace("{" + k + "}", qv)
p = urlparse(url)
if not p.netloc:
url = request.build_absolute_uri(url)
return url
def get_frontend_url(request, urlname, **kwargs):
from allauth import app_settings as allauth_settings
if allauth_settings.HEADLESS_ENABLED:
from allauth.headless import app_settings as headless_settings
url = headless_settings.FRONTEND_URLS.get(urlname)
if allauth_settings.HEADLESS_ONLY and not url:
raise ImproperlyConfigured(f"settings.HEADLESS_FRONTEND_URLS['{urlname}']")
if url:
return render_url(request, url, **kwargs)
return None
def headed_redirect_response(viewname):
"""
In some cases, we're redirecting to a non-headless view. In case of
headless-only mode, that view clearly does not exist.
"""
try:
return HttpResponseRedirect(reverse(viewname))
except NoReverseMatch:
if allauth_settings.HEADLESS_ONLY:
# The response we would be rendering here is not actually used.
return HttpResponseServerError()
raise

View File

@@ -0,0 +1,46 @@
import json
from django.http import HttpRequest
import pytest
from allauth.core.internal import httpkit
@pytest.mark.parametrize(
"url,params,expected_url",
[
("/", {"foo": "bar", "v": 1}, "/?foo=bar&v=1"),
(
"https://fqdn/?replace=this",
{"replace": "that"},
"https://fqdn/?replace=that",
),
],
)
def test_add_query_params(url, params, expected_url):
assert httpkit.add_query_params(url, params) == expected_url
@pytest.mark.parametrize(
"url_template,kwargs,expected_url",
[
("/foo", {}, "http://testserver/foo"),
("/foo?key={key}", {"key": " "}, "http://testserver/foo?key=+"),
("/foo/{key}", {"key": " "}, "http://testserver/foo/%20"),
("https://abs.org/foo?key={key}", {"key": " "}, "https://abs.org/foo?key=+"),
],
)
def test_render_url(url_template, kwargs, expected_url, rf):
request = rf.get("/")
assert httpkit.render_url(request, url_template, **kwargs) == expected_url
def test_deserialize_request(rf):
request = rf.get("/")
assert not request.is_secure()
serialized = httpkit.serialize_request(request)
assert not httpkit.deserialize_request(serialized, HttpRequest()).is_secure()
data = json.loads(serialized)
data["scheme"] = "https"
assert httpkit.deserialize_request(json.dumps(data), HttpRequest()).is_secure()

View File

@@ -0,0 +1,149 @@
import hashlib
import time
from collections import namedtuple
from typing import Optional
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponse
from django.shortcuts import render
from allauth import app_settings
from allauth.utils import import_callable
Rate = namedtuple("Rate", "amount duration per")
def _parse_duration(duration):
if len(duration) == 0:
raise ValueError(duration)
unit = duration[-1]
value = duration[0:-1]
unit_map = {"s": 1, "m": 60, "h": 3600, "d": 86400}
if unit not in unit_map:
raise ValueError("Invalid duration unit: %s" % unit)
if len(value) == 0:
value = 1
else:
value = float(value)
return value * unit_map[unit]
def _parse_rate(rate):
parts = rate.split("/")
if len(parts) == 2:
amount, duration = parts
per = "ip"
elif len(parts) == 3:
amount, duration, per = parts
else:
raise ValueError(rate)
amount = int(amount)
duration = _parse_duration(duration)
return Rate(amount, duration, per)
def _parse_rates(rates):
ret = []
if rates:
rates = rates.strip()
if rates:
parts = rates.split(",")
for part in parts:
ret.append(_parse_rate(part.strip()))
return ret
def _cache_key(request, *, action, rate, key=None, user=None):
from allauth.account.adapter import get_adapter
if rate.per == "ip":
source = ("ip", get_adapter().get_client_ip(request))
elif rate.per == "user":
if user is None:
if not request.user.is_authenticated:
raise ImproperlyConfigured(
"ratelimit configured per user but used anonymously"
)
user = request.user
source = ("user", str(user.pk))
elif rate.per == "key":
if key is None:
raise ImproperlyConfigured(
"ratelimit configured per key but no key specified"
)
key_hash = hashlib.sha256(key.encode("utf8")).hexdigest()
source = (key_hash,)
else:
raise ValueError(rate.per)
keys = ["allauth", "rl", action, *source]
return ":".join(keys)
def clear(request, *, action, key=None, user=None):
from allauth.account import app_settings
rates = _parse_rates(app_settings.RATE_LIMITS.get(action))
for rate in rates:
cache_key = _cache_key(request, action=action, rate=rate, key=key, user=user)
cache.delete(cache_key)
def consume(request, *, action, key=None, user=None, dry_run: bool = False):
from allauth.account import app_settings
if not request or request.method == "GET":
return True
rates = _parse_rates(app_settings.RATE_LIMITS.get(action))
if not rates:
return True
allowed = True
for rate in rates:
if not _consume_rate(
request, action=action, rate=rate, key=key, user=user, dry_run=dry_run
):
allowed = False
return allowed
def _consume_rate(request, *, action, rate, key=None, user=None, dry_run: bool = False):
cache_key = _cache_key(request, action=action, rate=rate, key=key, user=user)
history = cache.get(cache_key, [])
now = time.time()
while history and history[-1] <= now - rate.duration:
history.pop()
allowed = len(history) < rate.amount
if allowed and not dry_run:
history.insert(0, now)
cache.set(cache_key, history, rate.duration)
return allowed
def _handler429(request):
from allauth.account import app_settings
return render(request, "429." + app_settings.TEMPLATE_EXTENSION, status=429)
def respond_429(request) -> HttpResponse:
if app_settings.HEADLESS_ENABLED and hasattr(request.allauth, "headless"):
from allauth.headless.base.response import RateLimitResponse
return RateLimitResponse(request)
try:
handler429 = import_callable(settings.ROOT_URLCONF + ".handler429")
handler429 = import_callable(handler429)
except (ImportError, AttributeError):
handler429 = _handler429
return handler429(request)
def consume_or_429(request, *args, **kwargs) -> Optional[HttpResponse]:
if not consume(request, *args, **kwargs):
return respond_429(request)
return None

View File

@@ -0,0 +1,23 @@
import pytest
from allauth.core import ratelimit
@pytest.mark.parametrize(
"rate,values",
[
("5/m", [(5, 60, "ip")]),
("5/m/user", [(5, 60, "user")]),
("2/3.5m/key", [(2, 210, "key")]),
("3/5m/user,20/0.5m/ip", [(3, 300, "user"), (20, 30, "ip")]),
("7/2h", [(7, 7200, "ip")]),
("7/0.25d", [(7, 21600, "ip")]),
],
)
def test_parse(rate, values):
rates = ratelimit._parse_rates(rate)
assert len(rates) == len(values)
for i, rate in enumerate(rates):
assert rate.amount == values[i][0]
assert rate.duration == values[i][1]
assert rate.per == values[i][2]

View File

@@ -0,0 +1,17 @@
from functools import wraps
from allauth.core import ratelimit
def rate_limit(*, action, **rl_kwargs):
def decorator(function):
@wraps(function)
def wrap(request, *args, **kwargs):
resp = ratelimit.consume_or_429(request, action=action, **rl_kwargs)
if not resp:
resp = function(request, *args, **kwargs)
return resp
return wrap
return decorator

View File

@@ -0,0 +1,9 @@
import warnings
from allauth.core.exceptions import ImmediateHttpResponse
__all__ = ["ImmediateHttpResponse"]
warnings.warn("allauth.exceptions is deprecated, use allauth.core.exceptions")

View File

@@ -0,0 +1,218 @@
from django.core.exceptions import ImproperlyConfigured
from django.core.validators import validate_email
from allauth.account import app_settings as account_app_settings
from allauth.account.adapter import get_adapter as get_account_adapter
from allauth.account.forms import (
AddEmailForm,
BaseSignupForm,
ConfirmLoginCodeForm,
ReauthenticateForm,
RequestLoginCodeForm,
ResetPasswordForm,
UserTokenForm,
)
from allauth.account.internal import flows
from allauth.account.models import (
EmailAddress,
Login,
get_emailconfirmation_model,
)
from allauth.core import context
from allauth.headless.adapter import get_adapter
from allauth.headless.internal.restkit import inputs
class SignupInput(BaseSignupForm, inputs.Input):
password = inputs.CharField()
def clean_password(self):
password = self.cleaned_data["password"]
return get_account_adapter().clean_password(password)
class LoginInput(inputs.Input):
username = inputs.CharField(required=False)
email = inputs.EmailField(required=False)
password = inputs.CharField()
def clean(self):
cleaned_data = super().clean()
username = None
email = None
if (
account_app_settings.AUTHENTICATION_METHOD
== account_app_settings.AuthenticationMethod.USERNAME
):
username = cleaned_data.get("username")
missing_field = "username"
elif (
account_app_settings.AUTHENTICATION_METHOD
== account_app_settings.AuthenticationMethod.EMAIL
):
email = cleaned_data.get("email")
missing_field = "email"
elif (
account_app_settings.AUTHENTICATION_METHOD
== account_app_settings.AuthenticationMethod.USERNAME_EMAIL
):
username = cleaned_data.get("username")
email = cleaned_data.get("email")
missing_field = "email"
if email and username:
raise get_adapter().validation_error("email_or_username")
else:
raise ImproperlyConfigured(account_app_settings.AUTHENTICATION_METHOD)
if not email and not username:
if not self.errors.get(missing_field):
self.add_error(
missing_field, get_adapter().validation_error("required")
)
password = cleaned_data.get("password")
if password and (username or email):
credentials = {"password": password}
if email:
credentials["email"] = email
auth_method = account_app_settings.AuthenticationMethod.EMAIL
else:
credentials["username"] = username
auth_method = account_app_settings.AuthenticationMethod.USERNAME
user = get_account_adapter().authenticate(context.request, **credentials)
if user:
self.login = Login(user=user, email=credentials.get("email"))
if flows.login.is_login_rate_limited(context.request, self.login):
raise get_account_adapter().validation_error(
"too_many_login_attempts"
)
else:
error_code = "%s_password_mismatch" % auth_method.value
self.add_error(
"password", get_account_adapter().validation_error(error_code)
)
return cleaned_data
class VerifyEmailInput(inputs.Input):
key = inputs.CharField()
def clean_key(self):
key = self.cleaned_data["key"]
model = get_emailconfirmation_model()
confirmation = model.from_key(key)
valid = confirmation and not confirmation.key_expired()
if not valid:
raise get_account_adapter().validation_error(
"incorrect_code"
if account_app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED
else "invalid_or_expired_key"
)
if valid and not confirmation.email_address.can_set_verified():
raise get_account_adapter().validation_error("email_taken")
return confirmation
class RequestPasswordResetInput(ResetPasswordForm, inputs.Input):
pass
class ResetPasswordKeyInput(inputs.Input):
key = inputs.CharField()
def __init__(self, *args, **kwargs):
self.user = None
super().__init__(*args, **kwargs)
def clean_key(self):
key = self.cleaned_data["key"]
uidb36, _, subkey = key.partition("-")
token_form = UserTokenForm(data={"uidb36": uidb36, "key": subkey})
if not token_form.is_valid():
raise get_account_adapter().validation_error("invalid_password_reset")
self.user = token_form.reset_user
return key
class ResetPasswordInput(ResetPasswordKeyInput):
password = inputs.CharField()
def clean(self):
cleaned_data = super().clean()
password = self.cleaned_data.get("password")
if self.user and password is not None:
get_account_adapter().clean_password(password, user=self.user)
return cleaned_data
class ChangePasswordInput(inputs.Input):
current_password = inputs.CharField(required=False)
new_password = inputs.CharField()
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
self.fields["current_password"].required = self.user.has_usable_password()
def clean_current_password(self):
current_password = self.cleaned_data["current_password"]
if current_password:
if not self.user.check_password(current_password):
raise get_account_adapter().validation_error("enter_current_password")
return current_password
def clean_new_password(self):
new_password = self.cleaned_data["new_password"]
adapter = get_account_adapter()
return adapter.clean_password(new_password, user=self.user)
class AddEmailInput(AddEmailForm, inputs.Input):
pass
class SelectEmailInput(inputs.Input):
email = inputs.CharField()
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
def clean_email(self):
email = self.cleaned_data["email"]
validate_email(email)
try:
return EmailAddress.objects.get_for_user(user=self.user, email=email)
except EmailAddress.DoesNotExist:
raise get_adapter().validation_error("unknown_email")
class DeleteEmailInput(SelectEmailInput):
def clean_email(self):
email = super().clean_email()
if not flows.manage_email.can_delete_email(email):
raise get_account_adapter().validation_error("cannot_remove_primary_email")
return email
class MarkAsPrimaryEmailInput(SelectEmailInput):
primary = inputs.BooleanField(required=True)
def clean_email(self):
email = super().clean_email()
if not flows.manage_email.can_mark_as_primary(email):
raise get_account_adapter().validation_error("unverified_primary_email")
return email
class ReauthenticateInput(ReauthenticateForm, inputs.Input):
pass
class RequestLoginCodeInput(RequestLoginCodeForm, inputs.Input):
pass
class ConfirmLoginCodeInput(ConfirmLoginCodeForm, inputs.Input):
pass

View File

@@ -0,0 +1,44 @@
from allauth.headless.adapter import get_adapter
from allauth.headless.base.response import APIResponse
class RequestEmailVerificationResponse(APIResponse):
def __init__(self, request, verification_sent):
super().__init__(request, status=200 if verification_sent else 403)
class VerifyEmailResponse(APIResponse):
def __init__(self, request, verification, stage):
adapter = get_adapter()
data = {
"email": verification.email_address.email,
"user": adapter.serialize_user(verification.email_address.user),
}
meta = {
"is_authenticating": stage is not None,
}
super().__init__(request, data=data, meta=meta)
class EmailAddressesResponse(APIResponse):
def __init__(self, request, email_addresses):
data = [
{
"email": addr.email,
"verified": addr.verified,
"primary": addr.primary,
}
for addr in email_addresses
]
super().__init__(request, data=data)
class RequestPasswordResponse(APIResponse):
pass
class PasswordResetKeyResponse(APIResponse):
def __init__(self, request, user):
adapter = get_adapter()
data = {"user": adapter.serialize_user(user)}
super().__init__(request, data=data)

View File

@@ -0,0 +1,99 @@
from allauth.account.models import EmailAddress
def test_list_email(auth_client, user, headless_reverse):
resp = auth_client.get(
headless_reverse("headless:account:manage_email"),
)
assert len(resp.json()["data"]) == 1
def test_remove_email(auth_client, user, email_factory, headless_reverse):
addr = EmailAddress.objects.create(email=email_factory(), user=user)
assert EmailAddress.objects.filter(user=user).count() == 2
resp = auth_client.delete(
headless_reverse("headless:account:manage_email"),
data={"email": addr.email},
content_type="application/json",
)
assert resp.status_code == 200
assert len(resp.json()["data"]) == 1
assert not EmailAddress.objects.filter(pk=addr.pk).exists()
def test_add_email(auth_client, user, email_factory, headless_reverse):
new_email = email_factory()
resp = auth_client.post(
headless_reverse("headless:account:manage_email"),
data={"email": new_email},
content_type="application/json",
)
assert resp.status_code == 200
assert len(resp.json()["data"]) == 2
assert EmailAddress.objects.filter(email=new_email, verified=False).exists()
def test_change_primary(auth_client, user, email_factory, headless_reverse):
addr = EmailAddress.objects.create(
email=email_factory(), user=user, verified=True, primary=False
)
resp = auth_client.patch(
headless_reverse("headless:account:manage_email"),
data={"email": addr.email, "primary": True},
content_type="application/json",
)
assert resp.status_code == 200
assert len(resp.json()["data"]) == 2
assert EmailAddress.objects.filter(pk=addr.pk, primary=True).exists()
def test_resend_verification(
auth_client, user, email_factory, headless_reverse, mailoutbox
):
addr = EmailAddress.objects.create(email=email_factory(), user=user, verified=False)
resp = auth_client.put(
headless_reverse("headless:account:manage_email"),
data={"email": addr.email},
content_type="application/json",
)
assert resp.status_code == 200
assert len(mailoutbox) == 1
def test_email_rate_limit(
auth_client, user, email_factory, headless_reverse, settings, enable_cache
):
settings.ACCOUNT_RATE_LIMITS = {"manage_email": "1/m/ip"}
for attempt in range(2):
new_email = email_factory()
resp = auth_client.post(
headless_reverse("headless:account:manage_email"),
data={"email": new_email},
content_type="application/json",
)
expected_status = 200 if attempt == 0 else 429
assert resp.status_code == expected_status
assert resp.json()["status"] == expected_status
def test_resend_verification_rate_limit(
auth_client,
user,
email_factory,
headless_reverse,
settings,
enable_cache,
mailoutbox,
):
settings.ACCOUNT_RATE_LIMITS = {"confirm_email": "1/m/ip"}
for attempt in range(2):
addr = EmailAddress.objects.create(
email=email_factory(), user=user, verified=False
)
resp = auth_client.put(
headless_reverse("headless:account:manage_email"),
data={"email": addr.email},
content_type="application/json",
)
assert resp.status_code == 403 if attempt else 200
assert len(mailoutbox) == 1

View File

@@ -0,0 +1,220 @@
import copy
from unittest.mock import ANY
import pytest
@pytest.mark.parametrize(
"has_password,request_data,response_data,status_code",
[
# Wrong current password
(
True,
{"current_password": "wrong", "new_password": "{password_factory}"},
{
"status": 400,
"errors": [
{
"param": "current_password",
"message": "Please type your current password.",
"code": "enter_current_password",
}
],
},
400,
),
# Happy flow, regular password change
(
True,
{
"current_password": "{user_password}",
"new_password": "{password_factory}",
},
{
"status": 200,
"meta": {"is_authenticated": True},
"data": {
"user": ANY,
"methods": [],
},
},
200,
),
# New password does not match constraints
(
True,
{
"current_password": "{user_password}",
"new_password": "a",
},
{
"status": 400,
"errors": [
{
"param": "new_password",
"code": "password_too_short",
"message": "This password is too short. It must contain at least 6 characters.",
}
],
},
400,
),
# New password not empty
(
True,
{
"current_password": "{user_password}",
"new_password": "",
},
{
"status": 400,
"errors": [
{
"param": "new_password",
"code": "required",
"message": "This field is required.",
}
],
},
400,
),
# Current password not blank
(
True,
{
"current_password": "",
"new_password": "{password_factory}",
},
{
"status": 400,
"errors": [
{
"param": "current_password",
"message": "This field is required.",
"code": "required",
}
],
},
400,
),
# Current password missing
(
True,
{
"new_password": "{password_factory}",
},
{
"status": 400,
"errors": [
{
"param": "current_password",
"message": "This field is required.",
"code": "required",
}
],
},
400,
),
# Current password not set, happy flow
(
False,
{
"current_password": "",
"new_password": "{password_factory}",
},
{
"status": 200,
"meta": {"is_authenticated": True},
"data": {
"user": ANY,
"methods": [],
},
},
200,
),
# Current password not set, current_password absent
(
False,
{
"new_password": "{password_factory}",
},
{
"status": 200,
"meta": {"is_authenticated": True},
"data": {
"user": ANY,
"methods": [],
},
},
200,
),
],
)
def test_change_password(
auth_client,
user,
request_data,
response_data,
status_code,
has_password,
user_password,
password_factory,
settings,
mailoutbox,
headless_reverse,
headless_client,
):
request_data = copy.deepcopy(request_data)
response_data = copy.deepcopy(response_data)
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
if not has_password:
user.set_unusable_password()
user.save(update_fields=["password"])
auth_client.force_login(user)
if request_data.get("current_password") == "{user_password}":
request_data["current_password"] = user_password
if request_data.get("new_password") == "{password_factory}":
request_data["new_password"] = password_factory()
resp = auth_client.post(
headless_reverse("headless:account:change_password"),
data=request_data,
content_type="application/json",
)
assert resp.status_code == status_code
resp_json = resp.json()
if headless_client == "app" and resp.status_code == 200:
response_data["meta"]["session_token"] = ANY
assert resp_json == response_data
user.refresh_from_db()
if resp.status_code == 200:
assert user.check_password(request_data["new_password"])
assert len(mailoutbox) == 1
else:
assert user.check_password(user_password)
assert len(mailoutbox) == 0
def test_change_password_rate_limit(
enable_cache,
auth_client,
user,
user_password,
password_factory,
settings,
headless_reverse,
):
settings.ACCOUNT_RATE_LIMITS = {"change_password": "1/m/ip"}
for attempt in range(2):
new_password = password_factory()
resp = auth_client.post(
headless_reverse("headless:account:change_password"),
data={
"current_password": user_password,
"new_password": new_password,
},
content_type="application/json",
)
user_password = new_password
expected_status = 200 if attempt == 0 else 429
assert resp.status_code == expected_status
assert resp.json()["status"] == expected_status

View File

@@ -0,0 +1,86 @@
from allauth.account.models import (
EmailAddress,
EmailConfirmationHMAC,
get_emailconfirmation_model,
)
from allauth.headless.constants import Flow
def test_verify_email_other_user(auth_client, user, user_factory, headless_reverse):
other_user = user_factory(email_verified=False)
email_address = EmailAddress.objects.get(user=other_user, verified=False)
assert not email_address.verified
key = EmailConfirmationHMAC(email_address).key
resp = auth_client.post(
headless_reverse("headless:account:verify_email"),
data={"key": key},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.json()
# We're still authenticated as the user originally logged in, not the
# other_user.
assert data["data"]["user"]["id"] == user.pk
def test_auth_unverified_email(
client, user_factory, password_factory, settings, headless_reverse
):
settings.ACCOUNT_AUTHENTICATION_METHOD = "email"
settings.ACCOUNT_EMAIL_VERIFICATION = "mandatory"
password = password_factory()
user = user_factory(email_verified=False, password=password)
resp = client.post(
headless_reverse("headless:account:login"),
data={
"email": user.email,
"password": password,
},
content_type="application/json",
)
assert resp.status_code == 401
data = resp.json()
flows = data["data"]["flows"]
assert [f for f in flows if f["id"] == Flow.VERIFY_EMAIL][0]["is_pending"]
emailaddress = EmailAddress.objects.filter(user=user, verified=False).get()
key = get_emailconfirmation_model().create(emailaddress).key
resp = client.post(
headless_reverse("headless:account:verify_email"),
data={"key": key},
content_type="application/json",
)
assert resp.status_code == 200
def test_verify_email_bad_key(
client, settings, password_factory, user_factory, headless_reverse
):
settings.ACCOUNT_AUTHENTICATION_METHOD = "email"
settings.ACCOUNT_EMAIL_VERIFICATION = "mandatory"
password = password_factory()
user = user_factory(email_verified=False, password=password)
resp = client.post(
headless_reverse("headless:account:login"),
data={
"email": user.email,
"password": password,
},
content_type="application/json",
)
assert resp.status_code == 401
resp = client.post(
headless_reverse("headless:account:verify_email"),
data={"key": "bad"},
content_type="application/json",
)
assert resp.status_code == 400
assert resp.json() == {
"status": 400,
"errors": [
{
"code": "invalid_or_expired_key",
"param": "key",
"message": "Invalid or expired key.",
}
],
}

View File

@@ -0,0 +1,45 @@
from allauth.headless.constants import Flow
def test_email_verification_rate_limits(
client,
db,
user_password,
settings,
user_factory,
password_factory,
enable_cache,
headless_reverse,
):
settings.ACCOUNT_AUTHENTICATION_METHOD = "email"
settings.ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED = True
settings.ACCOUNT_EMAIL_VERIFICATION = "mandatory"
settings.ACCOUNT_RATE_LIMITS = {"confirm_email": "1/m/key"}
email = "user@email.org"
user_factory(email=email, email_verified=False, password=user_password)
for attempt in range(2):
resp = client.post(
headless_reverse("headless:account:login"),
data={
"email": email,
"password": user_password,
},
content_type="application/json",
)
if attempt == 0:
assert resp.status_code == 401
flow = [
flow for flow in resp.json()["data"]["flows"] if flow.get("is_pending")
][0]
assert flow["id"] == Flow.VERIFY_EMAIL
else:
assert resp.status_code == 400
assert resp.json() == {
"status": 400,
"errors": [
{
"message": "Too many failed login attempts. Try again later.",
"code": "too_many_login_attempts",
}
],
}

View File

@@ -0,0 +1,188 @@
from unittest.mock import ANY
import pytest
def test_auth_password_input_error(headless_reverse, client):
resp = client.post(
headless_reverse("headless:account:login"),
data={},
content_type="application/json",
)
assert resp.status_code == 400
assert resp.json() == {
"status": 400,
"errors": [
{
"message": "This field is required.",
"code": "required",
"param": "password",
},
{
"message": "This field is required.",
"code": "required",
"param": "username",
},
],
}
def test_auth_password_bad_password(headless_reverse, client, user, settings):
settings.ACCOUNT_AUTHENTICATION_METHOD = "email"
resp = client.post(
headless_reverse("headless:account:login"),
data={
"email": user.email,
"password": "wrong",
},
content_type="application/json",
)
assert resp.status_code == 400
assert resp.json() == {
"status": 400,
"errors": [
{
"param": "password",
"message": "The email address and/or password you specified are not correct.",
"code": "email_password_mismatch",
}
],
}
def test_auth_password_success(
client, user, user_password, settings, headless_reverse, headless_client
):
settings.ACCOUNT_AUTHENTICATION_METHOD = "email"
login_resp = client.post(
headless_reverse("headless:account:login"),
data={
"email": user.email,
"password": user_password,
},
content_type="application/json",
)
assert login_resp.status_code == 200
session_resp = client.get(
headless_reverse("headless:account:current_session"),
content_type="application/json",
)
assert session_resp.status_code == 200
for resp in [login_resp, session_resp]:
extra_meta = {}
if headless_client == "app" and resp == login_resp:
# The session is created on first login, and hence the token is
# exposed only at that moment.
extra_meta["session_token"] = ANY
assert resp.json() == {
"status": 200,
"data": {
"user": {
"id": user.pk,
"display": str(user),
"email": user.email,
"username": user.username,
"has_usable_password": True,
},
"methods": [
{
"at": ANY,
"email": user.email,
"method": "password",
}
],
},
"meta": {"is_authenticated": True, **extra_meta},
}
@pytest.mark.parametrize("is_active,status_code", [(False, 401), (True, 200)])
def test_auth_password_user_inactive(
client, user, user_password, settings, status_code, is_active, headless_reverse
):
user.is_active = is_active
user.save(update_fields=["is_active"])
resp = client.post(
headless_reverse("headless:account:login"),
data={
"username": user.username,
"password": user_password,
},
content_type="application/json",
)
assert resp.status_code == status_code
def test_login_failed_rate_limit(
client,
user,
settings,
headless_reverse,
headless_client,
enable_cache,
):
settings.ACCOUNT_RATE_LIMITS = {"login_failed": "1/m/ip"}
settings.ACCOUNT_AUTHENTICATION_METHOD = "email"
for attempt in range(2):
resp = client.post(
headless_reverse("headless:account:login"),
data={
"email": user.email,
"password": "wrong",
},
content_type="application/json",
)
assert resp.status_code == 400
assert resp.json()["errors"] == [
(
{
"code": "email_password_mismatch",
"message": "The email address and/or password you specified are not correct.",
"param": "password",
}
if attempt == 0
else {
"message": "Too many failed login attempts. Try again later.",
"code": "too_many_login_attempts",
}
)
]
def test_login_rate_limit(
client,
user,
user_password,
settings,
headless_reverse,
headless_client,
enable_cache,
):
settings.ACCOUNT_RATE_LIMITS = {"login": "1/m/ip"}
settings.ACCOUNT_AUTHENTICATION_METHOD = "email"
for attempt in range(2):
resp = client.post(
headless_reverse("headless:account:login"),
data={
"email": user.email,
"password": user_password,
},
content_type="application/json",
)
expected_status = 429 if attempt else 200
assert resp.status_code == expected_status
def test_login_already_logged_in(
auth_client, user, user_password, settings, headless_reverse
):
settings.ACCOUNT_AUTHENTICATION_METHOD = "email"
resp = auth_client.post(
headless_reverse("headless:account:login"),
data={
"email": user.email,
"password": user_password,
},
content_type="application/json",
)
assert resp.status_code == 409

View File

@@ -0,0 +1,146 @@
import time
from allauth.account.models import EmailAddress
from allauth.headless.constants import Flow
def test_login_by_code(headless_reverse, user, client, mailoutbox):
resp = client.post(
headless_reverse("headless:account:request_login_code"),
data={"email": user.email},
content_type="application/json",
)
assert resp.status_code == 401
data = resp.json()
assert [f for f in data["data"]["flows"] if f["id"] == Flow.LOGIN_BY_CODE][0][
"is_pending"
]
assert len(mailoutbox) == 1
code = [line for line in mailoutbox[0].body.splitlines() if len(line) == 6][0]
resp = client.post(
headless_reverse("headless:account:confirm_login_code"),
data={"code": code},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.json()
assert data["meta"]["is_authenticated"]
def test_login_by_code_rate_limit(
headless_reverse, user, client, mailoutbox, settings, enable_cache
):
settings.ACCOUNT_RATE_LIMITS = {"request_login_code": "1/m/ip"}
for attempt in range(2):
resp = client.post(
headless_reverse("headless:account:request_login_code"),
data={"email": user.email},
content_type="application/json",
)
expected_code = 400 if attempt else 401
assert resp.status_code == expected_code
data = resp.json()
assert data["status"] == expected_code
if expected_code == 400:
assert data["errors"] == [
{
"code": "too_many_login_attempts",
"message": "Too many failed login attempts. Try again later.",
"param": "email",
},
]
def test_login_by_code_max_attemps(headless_reverse, user, client, settings):
settings.ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS = 2
resp = client.post(
headless_reverse("headless:account:request_login_code"),
data={"email": user.email},
content_type="application/json",
)
assert resp.status_code == 401
for i in range(3):
resp = client.post(
headless_reverse("headless:account:confirm_login_code"),
data={"code": "wrong"},
content_type="application/json",
)
session_resp = client.get(
headless_reverse("headless:account:current_session"),
data={"code": "wrong"},
content_type="application/json",
)
assert session_resp.status_code == 401
pending_flows = [
f for f in session_resp.json()["data"]["flows"] if f.get("is_pending")
]
if i >= 1:
assert resp.status_code == 409 if i >= 2 else 400
assert len(pending_flows) == 0
else:
assert resp.status_code == 400
assert len(pending_flows) == 1
def test_login_by_code_required(
client, settings, user_factory, password_factory, headless_reverse, mailoutbox
):
settings.ACCOUNT_LOGIN_BY_CODE_REQUIRED = True
password = password_factory()
user = user_factory(password=password, email_verified=False)
email_address = EmailAddress.objects.get(email=user.email)
assert not email_address.verified
resp = client.post(
headless_reverse("headless:account:login"),
data={
"username": user.username,
"password": password,
},
content_type="application/json",
)
assert resp.status_code == 401
pending_flow = [f for f in resp.json()["data"]["flows"] if f.get("is_pending")][0][
"id"
]
assert pending_flow == Flow.LOGIN_BY_CODE
code = [line for line in mailoutbox[0].body.splitlines() if len(line) == 6][0]
resp = client.post(
headless_reverse("headless:account:confirm_login_code"),
data={"code": code},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.json()
assert data["meta"]["is_authenticated"]
email_address.refresh_from_db()
assert email_address.verified
def test_login_by_code_expired(headless_reverse, user, client, mailoutbox):
resp = client.post(
headless_reverse("headless:account:request_login_code"),
data={"email": user.email},
content_type="application/json",
)
assert resp.status_code == 401
data = resp.json()
assert [f for f in data["data"]["flows"] if f["id"] == Flow.LOGIN_BY_CODE][0][
"is_pending"
]
assert len(mailoutbox) == 1
code = [line for line in mailoutbox[0].body.splitlines() if len(line) == 6][0]
# Expire code
session = client.headless_session()
login = session["account_login"]
login["state"]["login_code"]["at"] = time.time() - 24 * 60 * 60
session["account_login"] = login
session.save()
# Post valid code
resp = client.post(
headless_reverse("headless:account:confirm_login_code"),
data={"code": code},
content_type="application/json",
)
assert resp.status_code == 409

View File

@@ -0,0 +1,52 @@
def test_reauthenticate(
auth_client, user, user_password, headless_reverse, headless_client
):
resp = auth_client.get(
headless_reverse("headless:account:current_session"),
content_type="application/json",
)
assert resp.status_code == 200
data = resp.json()
method_count = len(data["data"]["methods"])
resp = auth_client.post(
headless_reverse("headless:account:reauthenticate"),
data={
"password": user_password,
},
content_type="application/json",
)
assert resp.status_code == 200
resp = auth_client.get(
headless_reverse("headless:account:current_session"),
content_type="application/json",
)
assert resp.status_code == 200
data = resp.json()
assert len(data["data"]["methods"]) == method_count + 1
last_method = data["data"]["methods"][-1]
assert last_method["method"] == "password"
def test_reauthenticate_rate_limit(
auth_client,
user,
user_password,
headless_reverse,
headless_client,
settings,
enable_cache,
):
settings.ACCOUNT_RATE_LIMITS = {"reauthenticate": "1/m/ip"}
for attempt in range(4):
resp = auth_client.post(
headless_reverse("headless:account:reauthenticate"),
data={
"password": user_password,
},
content_type="application/json",
)
expected_status = 429 if attempt else 200
assert resp.status_code == expected_status
assert resp.json()["status"] == expected_status

View File

@@ -0,0 +1,151 @@
from django.urls import reverse
import pytest
def test_password_reset_flow(
client, user, mailoutbox, password_factory, settings, headless_reverse
):
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
resp = client.post(
headless_reverse("headless:account:request_password_reset"),
data={
"email": user.email,
},
content_type="application/json",
)
assert resp.status_code == 200
assert len(mailoutbox) == 1
body = mailoutbox[0].body
# Extract URL for `password_reset_from_key` view
url = body[body.find("/password/reset/") :].split()[0]
key = url.split("/")[-2]
password = password_factory()
# Too simple password
resp = client.post(
headless_reverse("headless:account:reset_password"),
data={
"key": key,
"password": "a",
},
content_type="application/json",
)
assert resp.status_code == 400
assert resp.json() == {
"status": 400,
"errors": [
{
"code": "password_too_short",
"message": "This password is too short. It must contain at least 6 characters.",
}
],
}
assert len(mailoutbox) == 1
# Success
resp = client.post(
headless_reverse("headless:account:reset_password"),
data={
"key": key,
"password": password,
},
content_type="application/json",
)
assert resp.status_code == 401
user.refresh_from_db()
assert user.check_password(password)
assert len(mailoutbox) == 2 # The security notification
@pytest.mark.parametrize("method", ["get", "post"])
def test_password_reset_flow_wrong_key(
client, password_factory, headless_reverse, method
):
password = password_factory()
if method == "get":
resp = client.get(
headless_reverse("headless:account:reset_password"),
HTTP_X_PASSWORD_RESET_KEY="wrong",
)
else:
resp = client.post(
headless_reverse("headless:account:reset_password"),
data={
"key": "wrong",
"password": password,
},
content_type="application/json",
)
assert resp.status_code == 400
assert resp.json() == {
"status": 400,
"errors": [
{
"param": "key",
"code": "invalid_password_reset",
"message": "The password reset token was invalid.",
}
],
}
def test_password_reset_flow_unknown_user(
client, db, mailoutbox, password_factory, settings, headless_reverse
):
resp = client.post(
headless_reverse("headless:account:request_password_reset"),
data={
"email": "not@registered.org",
},
content_type="application/json",
)
assert resp.status_code == 200
assert len(mailoutbox) == 1
body = mailoutbox[0].body
if getattr(settings, "HEADLESS_ONLY", False):
assert settings.HEADLESS_FRONTEND_URLS["account_signup"] in body
else:
assert reverse("account_signup") in body
def test_reset_password_rate_limit(
auth_client, user, headless_reverse, settings, enable_cache
):
settings.ACCOUNT_RATE_LIMITS = {"reset_password": "1/m/ip"}
for attempt in range(2):
resp = auth_client.post(
headless_reverse("headless:account:request_password_reset"),
data={"email": user.email},
content_type="application/json",
)
expected_status = 200 if attempt == 0 else 429
assert resp.status_code == expected_status
assert resp.json()["status"] == expected_status
def test_password_reset_key_rate_limit(
client,
user,
settings,
headless_reverse,
password_reset_key_generator,
enable_cache,
):
settings.ACCOUNT_RATE_LIMITS = {"reset_password_from_key": "1/m/ip"}
for attempt in range(2):
resp = client.post(
headless_reverse("headless:account:reset_password"),
data={
"key": password_reset_key_generator(user),
"password": "a", # too short
},
content_type="application/json",
)
expected_status = 429 if attempt else 400
assert resp.status_code == expected_status
assert resp.json()["status"] == expected_status

View File

@@ -0,0 +1,33 @@
from django.test.client import Client
from django.urls import reverse
def test_app_session_gone(db, user):
# intentionally use a vanilla Django test client
client = Client()
# Force login, creates a Django session
client.force_login(user)
# That Django session should not play any role.
resp = client.get(
reverse("headless:app:account:current_session"), HTTP_X_SESSION_TOKEN="gone"
)
assert resp.status_code == 410
def test_logout(auth_client, headless_reverse):
# That Django session should not play any role.
resp = auth_client.get(headless_reverse("headless:account:current_session"))
assert resp.status_code == 200
resp = auth_client.delete(headless_reverse("headless:account:current_session"))
assert resp.status_code == 401
resp = auth_client.get(headless_reverse("headless:account:current_session"))
assert resp.status_code in [401, 410]
def test_logout_no_token(app_client, user):
app_client.force_login(user)
resp = app_client.get(reverse("headless:app:account:current_session"))
assert resp.status_code == 200
resp = app_client.delete(reverse("headless:app:account:current_session"))
assert resp.status_code == 401
assert "session_token" not in resp.json()["meta"]

Some files were not shown because too many files have changed in this diff Show More