Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multi-Factor Authentication #3383

Merged
merged 25 commits into from
Aug 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5d36c5b
feat: Introduce login stages
pennersr Aug 17, 2023
817e886
feat(mfa): (De)activate flows
pennersr Aug 20, 2023
0ea2f9f
fix(mfa): Login broken due to missing `user` arg
pennersr Aug 20, 2023
3f7aa89
chore(mfa): TODO
pennersr Aug 20, 2023
ffe3bc6
feat(mfa): Recovery codes
pennersr Aug 21, 2023
a5291ca
feat(mfa): Require verified email
pennersr Aug 21, 2023
c809ed6
fix(mfa): (De)activate TOTP recognized recovery codes
pennersr Aug 21, 2023
cb0e7d1
tests(mfa): Add initial test suite
pennersr Aug 21, 2023
c31b934
refactor(mfa): Move encryption into the adapter
pennersr Aug 21, 2023
7bf4d5e
feat(mfa): Prevent add email
pennersr Aug 21, 2023
994900c
fix(mfa): Delete dangling recovery codes
pennersr Aug 21, 2023
021f148
feat(account): Reauthentication
pennersr Aug 22, 2023
48a5cc0
feat(mfa): Recovery codes flows
pennersr Aug 22, 2023
7403167
docs(mfa): Add basic docs
pennersr Aug 23, 2023
fcc0044
feat(account): Automatically remove dangling login
pennersr Aug 23, 2023
8f5f93a
chore(tox): Add MFA extras
pennersr Aug 23, 2023
497cbb1
docs(mfa): Document adapter class
pennersr Aug 25, 2023
9c61b28
tests(mfa): Improve coverage
pennersr Aug 25, 2023
f5b22de
docs(mfa): Release notes
pennersr Aug 25, 2023
5cadfc2
refactor(account): Rename to REAUTHENTICATION_TIMEOUT
pennersr Aug 25, 2023
08046bc
feat(mfa): Support for migrated recovery codes
pennersr Aug 25, 2023
5ba957f
feat(mfa): Require reauthentication for TOTP activation
pennersr Aug 25, 2023
419aba0
feat(mfa/recovery_codes): Allow encrypted migrated codes
pennersr Aug 26, 2023
738c494
fix(account): MFA related review comments
pennersr Aug 27, 2023
56926de
feat(mfa): Cleaned up templates
pennersr Aug 28, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion ChangeLog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@
Note worthy changes
-------------------

- ...
- Added builtin support for Two-Factor Authentication via the ``allauth.mfa`` app.


Backwards incompatible changes
------------------------------

- Dropped support for Django 3.1.

- The ``"allauth.account.middleware.AccountMiddleware"`` middleware is required to be present
in your ``settings.MIDDLEWARE``.


0.55.0 (2023-08-22)
*******************
Expand Down
12 changes: 11 additions & 1 deletion allauth/account/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from django.utils.encoding import force_str
from django.utils.translation import gettext_lazy as _

from allauth import ratelimit
from allauth import app_settings as allauth_app_settings, ratelimit
from allauth.account import signals
from allauth.account.app_settings import EmailVerificationMethod
from allauth.utils import (
Expand All @@ -53,6 +53,7 @@ class DefaultAccountAdapter(object):
"Too many failed login attempts. Try again later."
),
"email_taken": _("A user is already registered with this email address."),
"incorrect_password": _("Incorrect password."),
}

def __init__(self, request=None):
Expand Down Expand Up @@ -449,6 +450,8 @@ def post_login(
return response

def login(self, request, user):
from allauth.account.utils import record_authentication

# HACK: This is not nice. The proper Django way is to use an
# authentication backend
if not hasattr(user, "backend"):
Expand All @@ -467,6 +470,7 @@ def login(self, request, user):
backend_path = ".".join([backend.__module__, backend.__class__.__name__])
user.backend = backend_path
django_login(request, user)
record_authentication(request, user)

def logout(self, request):
django_logout(request)
Expand Down Expand Up @@ -667,6 +671,12 @@ def generate_emailconfirmation_key(self, email):
key = get_random_string(64).lower()
return key

def get_login_stages(self):
ret = []
if allauth_app_settings.MFA_ENABLED:
ret.append("allauth.mfa.stages.AuthenticateStage")
return ret


def get_adapter(request=None):
return import_attribute(app_settings.ADAPTER)(request)
6 changes: 6 additions & 0 deletions allauth/account/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ def RATE_LIMITS(self):
"reset_password": "20/m",
# Rate limit measured per individual email address
"reset_password_email": "5/m",
# Reauthentication for users already logged in)
"reauthenticate": "10/m",
# Password reset (the view the password reset email links to).
"reset_password_from_key": "20/m",
# Signups.
Expand Down Expand Up @@ -383,6 +385,10 @@ def PASSWORD_RESET_TOKEN_GENERATOR(self):
token_generator = EmailAwarePasswordResetTokenGenerator
return token_generator

@property
def REAUTHENTICATION_TIMEOUT(self):
return self._setting("REAUTHENTICATION_TIMEOUT", 300)


_app_settings = AppSettings("ACCOUNT_")

Expand Down
9 changes: 9 additions & 0 deletions allauth/account/apps.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext_lazy as _


class AccountConfig(AppConfig):
name = "allauth.account"
verbose_name = _("Accounts")
default_auto_field = "django.db.models.AutoField"

def ready(self):
required_mw = "allauth.account.middleware.AccountMiddleware"
if required_mw not in settings.MIDDLEWARE:
raise ImproperlyConfigured(
f"{required_mw} must be added to settings.MIDDLEWARE"
)
37 changes: 36 additions & 1 deletion allauth/account/decorators.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from functools import wraps

from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.http import urlencode

from .models import EmailAddress
from .utils import send_email_confirmation
from .utils import did_recently_authenticate, send_email_confirmation


def verified_email_required(
Expand Down Expand Up @@ -36,3 +41,33 @@ def _wrapped_view(request, *args, **kwargs):
if function:
return decorator(function)
return decorator


def reauthentication_required(function=None, redirect_field_name=REDIRECT_FIELD_NAME):
def decorator(view_func):
@wraps(view_func)
def _wrapper_view(request, *args, **kwargs):
path = request.get_full_path()
if request.user.is_anonymous:
redirect_url = (
reverse("account_login")
+ "?"
+ urlencode({redirect_field_name: path})
)
return HttpResponseRedirect(redirect_url)

if not did_recently_authenticate(request):
redirect_url = (
reverse("account_reauthenticate")
+ "?"
+ urlencode({redirect_field_name: path})
)
return HttpResponseRedirect(redirect_url)

return view_func(request, *args, **kwargs)

return _wrapper_view

if function:
return decorator(function)
return decorator
24 changes: 24 additions & 0 deletions allauth/account/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,8 @@ class AddEmailForm(UserForm):
)

def clean_email(self):
from allauth.account import signals

value = self.cleaned_data["email"]
value = get_adapter().clean_email(value)
errors = {
Expand All @@ -478,6 +480,12 @@ def clean_email(self):
raise forms.ValidationError(
errors["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):
Expand Down Expand Up @@ -646,3 +654,19 @@ def clean(self):
raise forms.ValidationError(self.error_messages["token_invalid"])

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 self.user.check_password(password):
raise forms.ValidationError(
get_adapter().error_messages["incorrect_password"]
)
return password
19 changes: 19 additions & 0 deletions allauth/account/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.conf import settings


class AccountMiddleware:
def __init__(self, get_response):
self.get_response = get_response
# One-time configuration and initialization.

def __call__(self, request):
response = self.get_response(request)
self._remove_dangling_login(request, response)
return response

def _remove_dangling_login(self, request, response):
if request.path.startswith(settings.STATIC_URL):
return
if not getattr(request, "_account_login_accessed", False):
if "account_login" in request.session:
request.session.pop("account_login")
84 changes: 83 additions & 1 deletion allauth/account/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime

from django.contrib.auth import get_user_model
from django.core import signing
from django.db import models
from django.db.models import Index, Q
Expand All @@ -12,7 +13,6 @@
from . import app_settings, signals
from .adapter import get_adapter
from .managers import EmailAddressManager, EmailConfirmationManager
from .utils import user_email


class EmailAddress(models.Model):
Expand Down Expand Up @@ -72,6 +72,8 @@ 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:
Expand All @@ -92,6 +94,8 @@ def send_confirmation(self, request=None, signup=False):
return confirmation

def remove(self):
from allauth.account.utils import user_email

self.delete()
if user_email(self.user) == self.email:
alt = (
Expand Down Expand Up @@ -191,3 +195,81 @@ def from_key(cls, key):
):
ret = None
return ret


class Login:
"""
Represents a user that is in the process of logging in.
"""

def __init__(
self,
user,
email_verification,
redirect_url=None,
signal_kwargs=None,
signup=False,
email=None,
state=None,
):
self.user = user
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

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),
"email_verification": self.email_verification,
"signup": self.signup,
"redirect_url": self.redirect_url,
"email": self.email,
"signal_kwargs": signal_kwargs,
"state": self.state,
}
return data

@classmethod
def deserialize(cls, data):
from allauth.account.utils import url_str_to_user_pk
from allauth.socialaccount.models import SocialLogin

user = (
get_user_model()
.objects.filter(pk=url_str_to_user_pk(data["user_pk"]))
.first()
)
if user is None:
raise ValueError()
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:
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"],
)
except KeyError:
raise ValueError()
3 changes: 3 additions & 0 deletions allauth/account/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@
email_added = Signal()
# Provides the arguments "request", "user", "email_address"
email_removed = Signal()

# Internal/private signal.
_add_email = Signal()
Loading