mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 14:51:09 -05:00
okay fine
This commit is contained in:
20
.venv/lib/python3.12/site-packages/allauth/__init__.py
Normal file
20
.venv/lib/python3.12/site-packages/allauth/__init__.py
Normal 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"
|
||||
826
.venv/lib/python3.12/site-packages/allauth/account/adapter.py
Normal file
826
.venv/lib/python3.12/site-packages/allauth/account/adapter.py
Normal 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)
|
||||
34
.venv/lib/python3.12/site-packages/allauth/account/admin.py
Normal file
34
.venv/lib/python3.12/site-packages/allauth/account/admin.py
Normal 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)
|
||||
@@ -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)
|
||||
21
.venv/lib/python3.12/site-packages/allauth/account/apps.py
Normal file
21
.venv/lib/python3.12/site-packages/allauth/account/apps.py
Normal 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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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, [])
|
||||
52
.venv/lib/python3.12/site-packages/allauth/account/checks.py
Normal file
52
.venv/lib/python3.12/site-packages/allauth/account/checks.py
Normal 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
|
||||
102
.venv/lib/python3.12/site-packages/allauth/account/decorators.py
Normal file
102
.venv/lib/python3.12/site-packages/allauth/account/decorators.py
Normal 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
|
||||
747
.venv/lib/python3.12/site-packages/allauth/account/forms.py
Normal file
747
.venv/lib/python3.12/site-packages/allauth/account/forms.py
Normal 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
|
||||
@@ -0,0 +1,4 @@
|
||||
from allauth.account.internal import flows
|
||||
|
||||
|
||||
__all__ = ["flows"]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
136
.venv/lib/python3.12/site-packages/allauth/account/managers.py
Normal file
136
.venv/lib/python3.12/site-packages/allauth/account/managers.py
Normal 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()
|
||||
@@ -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)
|
||||
@@ -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")]),
|
||||
),
|
||||
]
|
||||
@@ -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")]),
|
||||
),
|
||||
]
|
||||
@@ -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 []
|
||||
)
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)]
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
193
.venv/lib/python3.12/site-packages/allauth/account/mixins.py
Normal file
193
.venv/lib/python3.12/site-packages/allauth/account/mixins.py
Normal 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
|
||||
)
|
||||
335
.venv/lib/python3.12/site-packages/allauth/account/models.py
Normal file
335
.venv/lib/python3.12/site-packages/allauth/account/models.py
Normal 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
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
180
.venv/lib/python3.12/site-packages/allauth/account/stages.py
Normal file
180
.venv/lib/python3.12/site-packages/allauth/account/stages.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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/"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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."]
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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"))
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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<br/>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"
|
||||
85
.venv/lib/python3.12/site-packages/allauth/account/urls.py
Normal file
85
.venv/lib/python3.12/site-packages/allauth/account/urls.py
Normal 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",
|
||||
),
|
||||
]
|
||||
)
|
||||
406
.venv/lib/python3.12/site-packages/allauth/account/utils.py
Normal file
406
.venv/lib/python3.12/site-packages/allauth/account/utils.py
Normal 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
|
||||
1007
.venv/lib/python3.12/site-packages/allauth/account/views.py
Normal file
1007
.venv/lib/python3.12/site-packages/allauth/account/views.py
Normal file
File diff suppressed because it is too large
Load Diff
55
.venv/lib/python3.12/site-packages/allauth/app_settings.py
Normal file
55
.venv/lib/python3.12/site-packages/allauth/app_settings.py
Normal 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)
|
||||
324
.venv/lib/python3.12/site-packages/allauth/conftest.py
Normal file
324
.venv/lib/python3.12/site-packages/allauth/conftest.py
Normal 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()
|
||||
20
.venv/lib/python3.12/site-packages/allauth/core/context.py
Normal file
20
.venv/lib/python3.12/site-packages/allauth/core/context.py
Normal 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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
149
.venv/lib/python3.12/site-packages/allauth/core/ratelimit.py
Normal file
149
.venv/lib/python3.12/site-packages/allauth/core/ratelimit.py
Normal 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
|
||||
@@ -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]
|
||||
17
.venv/lib/python3.12/site-packages/allauth/decorators.py
Normal file
17
.venv/lib/python3.12/site-packages/allauth/decorators.py
Normal 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
|
||||
9
.venv/lib/python3.12/site-packages/allauth/exceptions.py
Normal file
9
.venv/lib/python3.12/site-packages/allauth/exceptions.py
Normal 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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.",
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user