Repository: pennersr/django-allauth
Pull request #4040 (2024-08-16 11:31:16)

Email verification by code

coveralls: 
[![Coverage Status](https://coveralls.io/builds/69263317/badge)](https://coveralls.io/builds/69263317)

Changes unknown
when pulling **f166ffa0531746c2df4e4bc200845eb5b3cf0d63 on feat/email-verification-by-code**
into ** on main**.


Patch:
diff --git a/ChangeLog.rst b/ChangeLog.rst
index 855051cf..bad34f59 100644
--- a/ChangeLog.rst
+++ b/ChangeLog.rst
@@ -1,7 +1,8 @@
 64.2.0 (unreleased)
 *******************
 
-- ...
+- Verifying email addresses by means of a code (instead of a link) is now supported.
+  See ``settings.ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED``.
 
 
 64.1.0 (2024-08-15)
diff --git a/allauth/account/adapter.py b/allauth/account/adapter.py
index d2533dd7..67214fb6 100644
--- a/allauth/account/adapter.py
+++ b/allauth/account/adapter.py
@@ -32,15 +32,12 @@ 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 signals
-from allauth.account.app_settings import AuthenticationMethod
+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
 
-from . import app_settings
-
 
 class DefaultAccountAdapter(BaseAdapter):
     """The adapter class allows you to override various functionality of the
@@ -119,7 +116,8 @@ class DefaultAccountAdapter(BaseAdapter):
             .exists()
         )
         login_by_email = (
-            app_settings.AUTHENTICATION_METHOD == AuthenticationMethod.EMAIL
+            app_settings.AUTHENTICATION_METHOD
+            == app_settings.AuthenticationMethod.EMAIL
         )
         if email_address.primary:
             if has_other:
@@ -527,9 +525,9 @@ class DefaultAccountAdapter(BaseAdapter):
         """
         Marks the email address as confirmed on the db
         """
-        from allauth.account.internal.flows import manage_email
+        from allauth.account.internal.flows import email_verification
 
-        return manage_email.verify_email(request, email_address)
+        return email_verification.verify_email(request, email_address)
 
     def set_password(self, user, password):
         user.set_password(password)
@@ -583,7 +581,9 @@ class DefaultAccountAdapter(BaseAdapter):
         """
         from allauth.account.internal import flows
 
-        return flows.manage_email.get_email_verification_url(request, emailconfirmation)
+        return flows.email_verification.get_email_verification_url(
+            request, emailconfirmation
+        )
 
     def should_send_confirmation_mail(self, request, email_address, signup):
         send_email = ratelimit.consume(
@@ -608,12 +608,20 @@ class DefaultAccountAdapter(BaseAdapter):
         self.send_mail("account/email/account_already_exists", email, ctx)
 
     def send_confirmation_mail(self, request, emailconfirmation, signup):
-        activate_url = self.get_email_confirmation_url(request, emailconfirmation)
         ctx = {
             "user": emailconfirmation.email_address.user,
-            "activate_url": activate_url,
-            "key": emailconfirmation.key,
         }
+        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:
@@ -760,7 +768,13 @@ class DefaultAccountAdapter(BaseAdapter):
             ctx.update(context)
         self.send_mail(template_prefix, email, ctx)
 
-    def generate_login_code(self):
+    def generate_login_code(self) -> str:
+        return self._generate_code()
+
+    def generate_email_verification_code(self) -> str:
+        return self._generate_code()
+
+    def _generate_code(self):
         forbidden_chars = "0OI18B2ZAEU"
         allowed_chars = string.ascii_uppercase + string.digits
         for ch in forbidden_chars:
diff --git a/allauth/account/app_settings.py b/allauth/account/app_settings.py
index 0db88074..e4cbe6f4 100644
--- a/allauth/account/app_settings.py
+++ b/allauth/account/app_settings.py
@@ -126,6 +126,18 @@ class AppSettings:
             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)
diff --git a/allauth/account/checks.py b/allauth/account/checks.py
index 04693fa6..4a060abd 100644
--- a/allauth/account/checks.py
+++ b/allauth/account/checks.py
@@ -39,4 +39,14 @@ def settings_check(app_configs, **kwargs):
                     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
diff --git a/allauth/account/forms.py b/allauth/account/forms.py
index 637f15a1..979f113f 100644
--- a/allauth/account/forms.py
+++ b/allauth/account/forms.py
@@ -509,9 +509,12 @@ class AddEmailForm(UserForm):
         if on_this_account:
             raise adapter.validation_error("duplicate_email")
         if (
+            # Email is taken by a different account...
             on_diff_account
-            and app_settings.PREVENT_ENUMERATION != "strict"
+            # 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):
@@ -527,7 +530,13 @@ class AddEmailForm(UserForm):
         return value
 
     def save(self, request):
-        if app_settings.CHANGE_EMAIL:
+        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"]
             )
@@ -709,7 +718,7 @@ class RequestLoginCodeForm(forms.Form):
         return email
 
 
-class ConfirmLoginCodeForm(forms.Form):
+class BaseConfirmCodeForm(forms.Form):
     code = forms.CharField(
         label=_("Code"),
         widget=forms.TextInput(
@@ -722,8 +731,15 @@ class ConfirmLoginCodeForm(forms.Form):
         super().__init__(*args, **kwargs)
 
     def clean_code(self):
-        code = self.cleaned_data.get("code").replace(" ", "").lower()
-        expected_code = self.code.replace(" ", "").lower()
-        if not self.code or code != expected_code:
+        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
diff --git a/allauth/account/internal/flows/__init__.py b/allauth/account/internal/flows/__init__.py
index 6bd97cbb..08ed3d7d 100644
--- a/allauth/account/internal/flows/__init__.py
+++ b/allauth/account/internal/flows/__init__.py
@@ -1,4 +1,6 @@
 from allauth.account.internal.flows import (
+    email_verification,
+    email_verification_by_code,
     login,
     login_by_code,
     logout,
@@ -13,6 +15,8 @@ from allauth.account.internal.flows import (
 __all__ = [
     "password_reset",
     "password_change",
+    "email_verification",
+    "email_verification_by_code",
     "login",
     "login_by_code",
     "logout",
diff --git a/allauth/account/internal/flows/email_verification.py b/allauth/account/internal/flows/email_verification.py
new file mode 100644
index 00000000..2eb0a3b2
--- /dev/null
+++ b/allauth/account/internal/flows/email_verification.py
@@ -0,0 +1,66 @@
+from django.contrib import messages
+from django.http import HttpRequest
+from django.urls import reverse
+
+from allauth.account import app_settings, 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 verify_email(request: HttpRequest, email_address: EmailAddress) -> bool:
+    """
+    Marks the email address as confirmed on the db
+    """
+    from allauth.account.models import EmailAddress
+    from allauth.account.utils import emit_email_changed
+
+    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 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)
+
+    signals.email_confirmed.send(
+        sender=EmailAddress,
+        request=request,
+        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
diff --git a/allauth/account/internal/flows/email_verification_by_code.py b/allauth/account/internal/flows/email_verification_by_code.py
new file mode 100644
index 00000000..88c2fc52
--- /dev/null
+++ b/allauth/account/internal/flows/email_verification_by_code.py
@@ -0,0 +1,105 @@
+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.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 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:
+        return None, None
+    if time.time() - data["at"] >= app_settings.EMAIL_VERIFICATION_BY_CODE_TIMEOUT:
+        request.session.pop(EMAIL_VERIFICATION_CODE_SESSION_KEY, None)
+        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:
+        request.session.pop(EMAIL_VERIFICATION_CODE_SESSION_KEY, None)
+        return False
+    else:
+        request.session[EMAIL_VERIFICATION_CODE_SESSION_KEY] = pending_verification
+        return True
diff --git a/allauth/account/internal/flows/login_by_code.py b/allauth/account/internal/flows/login_by_code.py
index 0abbf9f4..e71344ad 100644
--- a/allauth/account/internal/flows/login_by_code.py
+++ b/allauth/account/internal/flows/login_by_code.py
@@ -96,3 +96,9 @@ def perform_login_by_code(
         email=pending_login["email"],
     )
     return perform_login(request, login)
+
+
+def compare_code(*, actual, expected) -> bool:
+    actual = actual.replace(" ", "").lower()
+    expected = expected.replace(" ", "").lower()
+    return expected and actual == expected
diff --git a/allauth/account/internal/flows/manage_email.py b/allauth/account/internal/flows/manage_email.py
index f13b73f8..36a0405d 100644
--- a/allauth/account/internal/flows/manage_email.py
+++ b/allauth/account/internal/flows/manage_email.py
@@ -1,6 +1,5 @@
 from django.contrib import messages
 from django.http import HttpRequest
-from django.urls import reverse
 
 from allauth.account import app_settings, signals
 from allauth.account.adapter import get_adapter
@@ -8,8 +7,6 @@ from allauth.account.internal.flows.reauthentication import (
     raise_if_reauthentication_required,
 )
 from allauth.account.models import EmailAddress
-from allauth.core.internal.httpkit import get_frontend_url
-from allauth.utils import build_absolute_uri
 
 
 def can_delete_email(email_address: EmailAddress) -> bool:
@@ -114,42 +111,3 @@ def mark_as_primary(request: HttpRequest, email_address: EmailAddress):
         emit_email_changed(request, from_email_address, email_address)
         success = True
     return success
-
-
-def verify_email(request: HttpRequest, email_address: EmailAddress) -> bool:
-    """
-    Marks the email address as confirmed on the db
-    """
-    from allauth.account.models import EmailAddress
-    from allauth.account.utils import emit_email_changed
-
-    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):
-        return False
-    email_address.set_as_primary(conditional=(not app_settings.CHANGE_EMAIL))
-    email_address.save(update_fields=["verified", "primary"])
-    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)
-    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
diff --git a/allauth/account/internal/flows/signup.py b/allauth/account/internal/flows/signup.py
index d8d757c0..807e075f 100644
--- a/allauth/account/internal/flows/signup.py
+++ b/allauth/account/internal/flows/signup.py
@@ -4,6 +4,7 @@ from django.urls import reverse
 
 from allauth.account import app_settings
 from allauth.account.adapter import get_adapter
+from allauth.account.internal.flows import email_verification_by_code
 from allauth.core.internal.httpkit import get_frontend_url
 from allauth.utils import build_absolute_uri
 
@@ -17,6 +18,10 @@ def prevent_enumeration(request: HttpRequest, email: str) -> HttpResponse:
         "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
 
diff --git a/allauth/account/models.py b/allauth/account/models.py
index 041907e5..7951a90e 100644
--- a/allauth/account/models.py
+++ b/allauth/account/models.py
@@ -128,11 +128,6 @@ class EmailConfirmationMixin:
         if not email_address.verified:
             confirmed = get_adapter().confirm_email(request, email_address)
             if confirmed:
-                signals.email_confirmed.send(
-                    sender=self.__class__,
-                    request=request,
-                    email_address=email_address,
-                )
                 return email_address
 
     def send(self, request=None, signup=False):
@@ -323,7 +318,13 @@ class Login:
 
 
 def get_emailconfirmation_model():
-    if app_settings.EMAIL_CONFIRMATION_HMAC:
+    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
diff --git a/allauth/account/stages.py b/allauth/account/stages.py
index 0c31ff22..d5ec518b 100644
--- a/allauth/account/stages.py
+++ b/allauth/account/stages.py
@@ -1,3 +1,6 @@
+from django.http import HttpResponseRedirect
+from django.urls import reverse
+
 from allauth.account.adapter import get_adapter
 from allauth.account.app_settings import EmailVerificationMethod
 from allauth.account.utils import resume_login, stash_login, unstash_login
@@ -26,6 +29,10 @@ class LoginStage:
         self.controller.set_handled(self.key)
         return resume_login(self.request, self.login)
 
+    def abort(self):
+        unstash_login(self.request)
+        return HttpResponseRedirect(reverse("account_login"))
+
 
 class LoginStageController:
     def __init__(self, request, login):
diff --git a/allauth/account/views.py b/allauth/account/views.py
index 35b1d0de..8f44ff4f 100644
--- a/allauth/account/views.py
+++ b/allauth/account/views.py
@@ -18,6 +18,7 @@ from allauth.account.adapter import get_adapter
 from allauth.account.forms import (
     AddEmailForm,
     ChangePasswordForm,
+    ConfirmEmailVerificationCodeForm,
     ConfirmLoginCodeForm,
     LoginForm,
     ReauthenticateForm,
@@ -42,6 +43,7 @@ from allauth.account.models import (
     EmailConfirmation,
     get_emailconfirmation_model,
 )
+from allauth.account.stages import EmailVerificationStage, LoginStageController
 from allauth.account.utils import (
     complete_signup,
     perform_login,
@@ -239,22 +241,10 @@ class ConfirmEmailView(NextRedirectMixin, LogoutFunctionalityMixin, TemplateView
         self.object = confirmation = self.get_object()
         email_address = confirmation.confirm(self.request)
         if not email_address:
-            get_adapter(self.request).add_message(
-                self.request,
-                messages.ERROR,
-                "account/messages/email_confirmation_failed.txt",
-                {"email": confirmation.email_address.email},
-            )
             return self.respond(False)
 
         self.logout_other_user(self.object)
 
-        get_adapter(self.request).add_message(
-            self.request,
-            messages.SUCCESS,
-            "account/messages/email_confirmed.txt",
-            {"email": confirmation.email_address.email},
-        )
         if app_settings.LOGIN_ON_EMAIL_CONFIRMATION:
             resp = self.login_on_confirm(confirmation)
             if resp is not None:
@@ -376,6 +366,7 @@ class EmailView(AjaxCapableProcessFormViewMixin, FormView):
         return get_form_class(app_settings.FORMS, "add_email", self.form_class)
 
     def dispatch(self, request, *args, **kwargs):
+        self._did_send_verification_email = False
         sync_user_email_addresses(request.user)
         return super().dispatch(request, *args, **kwargs)
 
@@ -386,12 +377,13 @@ class EmailView(AjaxCapableProcessFormViewMixin, FormView):
 
     def form_valid(self, form):
         flows.manage_email.add_email(self.request, form)
+        self._did_send_verification_email = True
         return super().form_valid(form)
 
     def post(self, request, *args, **kwargs):
         res = None
         if "action_add" in request.POST:
-            res = super(EmailView, self).post(request, *args, **kwargs)
+            res = super().post(request, *args, **kwargs)
         elif request.POST.get("email"):
             if "action_send" in request.POST:
                 res = self._action_send(request)
@@ -399,6 +391,7 @@ class EmailView(AjaxCapableProcessFormViewMixin, FormView):
                 res = self._action_remove(request)
             elif "action_primary" in request.POST:
                 res = self._action_primary(request)
+
             res = res or HttpResponseRedirect(self.get_success_url())
             # Given that we bypassed AjaxCapableProcessFormViewMixin,
             # we'll have to call invoke it manually...
@@ -426,6 +419,9 @@ class EmailView(AjaxCapableProcessFormViewMixin, FormView):
             send_email_confirmation(
                 self.request, request.user, email=email_address.email
             )
+            self._did_send_verification_email = True
+        if app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED:
+            return HttpResponseRedirect(reverse("account_email_verification_sent"))
 
     def _action_remove(self, request, *args, **kwargs):
         email_address = self._get_email_address(request)
@@ -483,6 +479,14 @@ class EmailView(AjaxCapableProcessFormViewMixin, FormView):
             )
         return data
 
+    def get_success_url(self):
+        if (
+            self._did_send_verification_email
+            and app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED
+        ):
+            return reverse("account_email_verification_sent")
+        return self.success_url
+
 
 email = EmailView.as_view()
 
@@ -750,7 +754,88 @@ class EmailVerificationSentView(TemplateView):
     template_name = "account/verification_sent." + app_settings.TEMPLATE_EXTENSION
 
 
-email_verification_sent = EmailVerificationSentView.as_view()
+class ConfirmEmailVerificationCodeView(FormView):
+    template_name = (
+        "account/confirm_email_verification_code." + app_settings.TEMPLATE_EXTENSION
+    )
+    form_class = ConfirmEmailVerificationCodeForm
+
+    def dispatch(self, request, *args, **kwargs):
+        self.stage = LoginStageController.enter(request, EmailVerificationStage.key)
+        self.verification, self.pending_verification = (
+            flows.email_verification_by_code.get_pending_verification(
+                request, peek=True
+            )
+        )
+        # preventing enumeration?
+        verification_is_fake = (
+            self.pending_verification and "code" not in self.pending_verification
+        )
+        # Can we at all continue?
+        if (
+            # No verification pending?
+            (
+                not self.pending_verification
+            )  # Anonymous, yet no stage (or fake verifcation)?
+            or (
+                request.user.is_anonymous
+                and not self.stage
+                and not verification_is_fake
+            )
+        ):
+            return HttpResponseRedirect(
+                reverse(
+                    "account_login" if request.user.is_anonymous else "account_email"
+                )
+            )
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_form_class(self):
+        return get_form_class(
+            app_settings.FORMS, "confirm_email_verification_code", self.form_class
+        )
+
+    def get_form_kwargs(self):
+        ret = super().get_form_kwargs()
+        ret["code"] = self.verification.key if self.verification else ""
+        return ret
+
+    def get_context_data(self, **kwargs):
+        ret = super().get_context_data(**kwargs)
+        ret["email"] = self.pending_verification["email"]
+        ret["cancel_url"] = (
+            reverse("account_login") if self.stage else reverse("account_email")
+        )
+        return ret
+
+    def form_valid(self, form):
+        email_address = self.verification.confirm(self.request)
+        if self.stage:
+            if not email_address:
+                return self.stage.abort()
+            return self.stage.exit()
+        return HttpResponseRedirect(reverse("account_email"))
+
+    def form_invalid(self, form):
+        attempts_left = flows.email_verification_by_code.record_invalid_attempt(
+            self.request, self.pending_verification
+        )
+        if attempts_left:
+            return super().form_invalid(form)
+        adapter = get_adapter(self.request)
+        adapter.add_message(
+            self.request,
+            messages.ERROR,
+            message=adapter.error_messages["too_many_login_attempts"],
+        )
+        return HttpResponseRedirect(reverse("account_login"))
+
+
+def email_verification_sent(request):
+    if app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED:
+        return ConfirmEmailVerificationCodeView.as_view()(request)
+    else:
+        return EmailVerificationSentView.as_view()(request)
 
 
 class BaseReauthenticateView(NextRedirectMixin, FormView):
diff --git a/allauth/headless/account/inputs.py b/allauth/headless/account/inputs.py
index 18531e10..9e4298af 100644
--- a/allauth/headless/account/inputs.py
+++ b/allauth/headless/account/inputs.py
@@ -94,13 +94,15 @@ class VerifyEmailInput(inputs.Input):
         key = self.cleaned_data["key"]
         model = get_emailconfirmation_model()
         confirmation = model.from_key(key)
-        if (
-            not confirmation
-            or confirmation.key_expired()
-            or not confirmation.email_address.can_set_verified()
-        ):
-            raise get_account_adapter().validation_error("invalid_or_expired_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
 
 
diff --git a/allauth/headless/base/response.py b/allauth/headless/base/response.py
index cd883c6c..27cef3e6 100644
--- a/allauth/headless/base/response.py
+++ b/allauth/headless/base/response.py
@@ -116,6 +116,7 @@ def get_config_data(request):
     data = {
         "authentication_method": account_settings.AUTHENTICATION_METHOD,
         "is_open_for_signup": get_account_adapter().is_open_for_signup(request),
+        "email_verification_by_code_enabled": account_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED,
     }
     return {"account": data}
 
diff --git a/allauth/socialaccount/providers/bitbucket_oauth2/views.py b/allauth/socialaccount/providers/bitbucket_oauth2/views.py
index 61179067..bf023fee 100644
--- a/allauth/socialaccount/providers/bitbucket_oauth2/views.py
+++ b/allauth/socialaccount/providers/bitbucket_oauth2/views.py
@@ -21,11 +21,12 @@ class BitbucketOAuth2Adapter(OAuth2Adapter):
             .get(self.profile_url, params={"access_token": token.token})
         )
         extra_data = resp.json()
-        if app_settings.QUERY_EMAIL and not extra_data.get("email"):
-            extra_data["email"] = self.get_email(token)
+        if app_settings.QUERY_EMAIL:
+            if email := self.get_email(token):
+                extra_data["email"] = email
         return self.get_provider().sociallogin_from_response(request, extra_data)
 
-    def get_email(self, token):
+    def get_email(self, token) -> str:
         """Fetches email address from email API endpoint"""
         resp = (
             get_adapter()
diff --git a/allauth/socialaccount/providers/github/views.py b/allauth/socialaccount/providers/github/views.py
index b25e883c..c4d84c9d 100644
--- a/allauth/socialaccount/providers/github/views.py
+++ b/allauth/socialaccount/providers/github/views.py
@@ -1,3 +1,5 @@
+from typing import Optional
+
 from allauth.socialaccount import app_settings
 from allauth.socialaccount.adapter import get_adapter
 from allauth.socialaccount.providers.oauth2.views import (
@@ -30,13 +32,12 @@ class GitHubOAuth2Adapter(OAuth2Adapter):
         )
         resp.raise_for_status()
         extra_data = resp.json()
-        if app_settings.QUERY_EMAIL and not extra_data.get("email"):
-            emails = self.get_emails(headers)
-            if emails:
+        if app_settings.QUERY_EMAIL:
+            if emails := self.get_emails(headers):
                 extra_data["emails"] = emails
         return self.get_provider().sociallogin_from_response(request, extra_data)
 
-    def get_emails(self, headers):
+    def get_emails(self, headers) -> Optional[list]:
         resp = (
             get_adapter().get_requests_session().get(self.emails_url, headers=headers)
         )
diff --git a/allauth/templates/account/confirm_email_verification_code.html b/allauth/templates/account/confirm_email_verification_code.html
new file mode 100644
index 00000000..ccea8f0c
--- /dev/null
+++ b/allauth/templates/account/confirm_email_verification_code.html
@@ -0,0 +1,36 @@
+{% extends "account/base_entrance.html" %}
+{% load i18n %}
+{% load allauth account %}
+{% block head_title %}
+    {% translate "Email Verification" %}
+{% endblock head_title %}
+{% block content %}
+    {% element h1 %}
+        {% translate "Enter Email Verification Code" %}
+    {% endelement %}
+    {% setvar email_link %}
+        <a href="mailto:{{ email }}">{{ email }}</a>
+    {% endsetvar %}
+    {% element p %}
+        {% blocktranslate %}We’ve sent a code to {{ email_link }}. The code expires shortly, so please enter it soon.{% endblocktranslate %}
+    {% endelement %}
+    {% url 'account_email_verification_sent' as action_url %}
+    {% element form form=form method="post" action=action_url tags="entrance,email,verification" %}
+        {% slot body %}
+            {% csrf_token %}
+            {% element fields form=form unlabeled=True %}
+            {% endelement %}
+            {{ redirect_field }}
+        {% endslot %}
+        {% slot actions %}
+            {% element button type="submit" tags="prominent,confirm" %}
+                {% translate "Confirm" %}
+            {% endelement %}
+        {% endslot %}
+    {% endelement %}
+    {% element h2 %}
+        {% element button href=cancel_url tags="link,cancel" %}
+            {% translate "Cancel" %}
+        {% endelement %}
+    {% endelement %}
+{% endblock content %}
diff --git a/allauth/templates/account/email/email_confirmation_message.txt b/allauth/templates/account/email/email_confirmation_message.txt
index 3ee4d2f3..ed9d0093 100644
--- a/allauth/templates/account/email/email_confirmation_message.txt
+++ b/allauth/templates/account/email/email_confirmation_message.txt
@@ -2,6 +2,8 @@
 {% load account %}
 {% load i18n %}
 
-{% block content %}{% autoescape off %}{% user_display user as user_display %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}You're receiving this email because user {{ user_display }} has given your email address to register an account on {{ site_domain }}.
+{% block content %}{% autoescape off %}{% user_display user as user_display %}{% blocktranslate with site_name=current_site.name site_domain=current_site.domain %}You're receiving this email because user {{ user_display }} has given your email address to register an account on {{ site_domain }}.{% endblocktranslate %}
 
-To confirm this is correct, go to {{ activate_url }}{% endblocktrans %}{% endautoescape %}{% endblock content %}
+{% if code %}{% blocktranslate %}Your email verification code is listed below. Please enter it in your open browser window.{% endblocktranslate %}
+
+{{ code }}{% else %}{% blocktranslate %}To confirm this is correct, go to {{ activate_url }}{% endblocktranslate %}{% endif %}{% endautoescape %}{% endblock content %}
diff --git a/docs/account/configuration.rst b/docs/account/configuration.rst
index f13cf99f..f8016ed4 100644
--- a/docs/account/configuration.rst
+++ b/docs/account/configuration.rst
@@ -73,6 +73,18 @@ Available settings:
   verification mail is still sent, whereas in case of "none" no email
   verification mails are sent.
 
+``ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED`` (default: ``False``)
+  Constrols whether email verification is performed by means of following a link
+  in the email (``False``), or by entering a code (``True``).
+
+``ACCOUNT_EMAIL_VERIFICATION_BY_CODE_MAX_ATTEMPTS`` (default: ``3``)
+  This setting controls the maximum number of attempts the user has at inputting
+  a valid code.
+
+``ACCOUNT_EMAIL_VERIFICATION_BY_CODE_TIMEOUT`` (default: ``900``)
+  The code that is emailed has a limited life span. It expires this many seconds after
+  which it was sent.
+
 ``ACCOUNT_EMAIL_SUBJECT_PREFIX`` (default: ``"[Site] "``)
   Subject-line prefix to use for email messages sent. By default, the
   name of the current ``Site`` (``django.contrib.sites``) is used.
diff --git a/docs/headless/openapi-specification/openapi.yaml b/docs/headless/openapi-specification/openapi.yaml
index 4d7d3722..33a4a516 100644
--- a/docs/headless/openapi-specification/openapi.yaml
+++ b/docs/headless/openapi-specification/openapi.yaml
@@ -211,7 +211,11 @@ paths:
         - "Authentication: Account"
       summary: Verify an email
       description: |
-        Complete the email verification process.
+        Complete the email verification process. Depending on the configuration,
+        email addresses are either verified by opening a link that is sent to
+        their email address, or, by inputting a code that is sent. On the API,
+        both cases are handled identically. Meaning, the required key is either
+        the one from the link, or, the code itself.
       parameters:
         - $ref: "#/components/parameters/Client"
         - $ref: "#/components/parameters/SessionToken"
@@ -980,7 +984,7 @@ paths:
   ######################################################################
   # Account: WebAuthn
   ######################################################################
-  /_allauth/{client}/v1/account/webauthn:
+  /_allauth/{client}/v1/account/authenticators/webauthn:
     get:
       tags:
         - "Account: WebAuthn"
@@ -1694,9 +1698,12 @@ components:
             - username_email
         is_open_for_signup:
           type: boolean
+        email_verification_by_code_enabled:
+          type: boolean
       required:
         - authentication_method
         - is_open_for_signup
+        - email_verification_by_code_enabled
     AuthenticationResponse:
       type: object
       description: |
diff --git a/examples/react-spa/frontend/src/Router.js b/examples/react-spa/frontend/src/Router.js
index 24bbe164..1eed7171 100644
--- a/examples/react-spa/frontend/src/Router.js
+++ b/examples/react-spa/frontend/src/Router.js
@@ -16,6 +16,7 @@ import Home from './Home'
 import ChangeEmail from './account/ChangeEmail'
 import ManageProviders from './socialaccount/ManageProviders'
 import VerifyEmail, { loader as verifyEmailLoader } from './account/VerifyEmail'
+import VerifyEmailByCode from './account/VerifyEmailByCode'
 import VerificationEmailSent from './account/VerificationEmailSent'
 import RequestPasswordReset from './account/RequestPasswordReset'
 import ChangePassword from './account/ChangePassword'
@@ -36,8 +37,9 @@ import ReauthenticateTOTP from './mfa/ReauthenticateTOTP'
 import Reauthenticate from './account/Reauthenticate'
 import Sessions from './usersessions/Sessions'
 import Root from './Root'
+import { useConfig } from './auth/hooks'
 
-function createRouter () {
+function createRouter (config) {
   return createBrowserRouter([
     {
       path: '/',
@@ -89,7 +91,7 @@ function createRouter () {
         },
         {
           path: '/account/verify-email',
-          element: <VerificationEmailSent />
+          element: config.data.account.email_verification_by_code_enabled ? <VerifyEmailByCode /> : <VerificationEmailSent />
         },
         {
           path: '/account/verify-email/:key',
@@ -184,8 +186,9 @@ export default function Router () {
   // even before the <AuthContext/> trigger the initial loading of the auth.
   // state.
   const [router, setRouter] = useState(null)
+  const config = useConfig()
   useEffect(() => {
-    setRouter(createRouter())
-  }, [])
+    setRouter(createRouter(config))
+  }, [config])
   return router ? <RouterProvider router={router} /> : null
 }
diff --git a/examples/react-spa/frontend/src/account/ChangeEmail.js b/examples/react-spa/frontend/src/account/ChangeEmail.js
index 9e6bf57e..1be3f74f 100644
--- a/examples/react-spa/frontend/src/account/ChangeEmail.js
+++ b/examples/react-spa/frontend/src/account/ChangeEmail.js
@@ -1,10 +1,14 @@
 import { useState, useEffect } from 'react'
+import { Navigate } from 'react-router-dom'
 import * as allauth from '../lib/allauth'
 import FormErrors from '../components/FormErrors'
 import Button from '../components/Button'
+import { useConfig } from '../auth/hooks'
 
 export default function ChangeEmail () {
+  const config = useConfig()
   const [email, setEmail] = useState('')
+  const [redirectToVerification, setRedirectToVerification] = useState(false)
   const [emailAddresses, setEmailAddresses] = useState([])
   const [response, setResponse] = useState({ fetching: false, content: { status: 200, data: [] } })
 
@@ -19,6 +23,12 @@ export default function ChangeEmail () {
     })
   }, [])
 
+  function requestRedirectToVerification () {
+    if (config.data.account.email_verification_by_code_enabled) {
+      setRedirectToVerification(true)
+    }
+  }
+
   function addEmail () {
     setResponse({ ...response, fetching: true })
     allauth.addEmail(email).then((resp) => {
@@ -26,6 +36,7 @@ export default function ChangeEmail () {
       if (resp.status === 200) {
         setEmailAddresses(resp.data)
         setEmail('')
+        requestRedirectToVerification()
       }
     }).catch((e) => {
       console.error(e)
@@ -37,7 +48,11 @@ export default function ChangeEmail () {
 
   function requestEmailVerification (email) {
     setResponse({ ...response, fetching: true })
-    allauth.requestEmailVerification(email).catch((e) => {
+    allauth.requestEmailVerification(email).then((resp) => {
+      if (resp.status === 200) {
+        requestRedirectToVerification()
+      }
+    }).catch((e) => {
       console.error(e)
       window.alert(e)
     }).then(() => {
@@ -75,6 +90,10 @@ export default function ChangeEmail () {
     })
   }
 
+  if (redirectToVerification) {
+    return <Navigate to='/account/verify-email' />
+  }
+
   return (
     <div>
       <h1>Change Email</h1>
diff --git a/examples/react-spa/frontend/src/account/VerifyEmailByCode.js b/examples/react-spa/frontend/src/account/VerifyEmailByCode.js
new file mode 100644
index 00000000..0f656bff
--- /dev/null
+++ b/examples/react-spa/frontend/src/account/VerifyEmailByCode.js
@@ -0,0 +1,46 @@
+import { useState } from 'react'
+import FormErrors from '../components/FormErrors'
+import {
+  Navigate
+} from 'react-router-dom'
+import { getEmailVerification, verifyEmail } from '../lib/allauth'
+import Button from '../components/Button'
+
+export default function VerifyEmail () {
+  const [code, setCode] = useState('')
+  const [response, setResponse] = useState({ fetching: false, content: null })
+
+  function submit () {
+    setResponse({ ...response, fetching: true })
+    verifyEmail(code).then((content) => {
+      setResponse((r) => { return { ...r, content } })
+    }).catch((e) => {
+      console.error(e)
+      window.alert(e)
+    }).then(() => {
+      setResponse((r) => { return { ...r, fetching: false } })
+    })
+  }
+
+  if ([200, 401].includes(response.content?.status)) {
+    return <Navigate to='/account/email' />
+  }
+
+  const body = (
+    <>
+      <p>Please confirm.</p>
+      <Button disabled={response.fetching} onClick={() => submit()}>Confirm</Button>
+    </>
+  )
+  return (
+    <div>
+      <h1>Confirm Email Address</h1>
+      <FormErrors errors={response.content?.errors} />
+
+      <div><label>Code <input value={code} onChange={(e) => setCode(e.target.value)} type='code' required /></label>
+        <FormErrors param='key' errors={response.content?.errors} />
+      </div>
+      <Button disabled={response.fetching} onClick={() => submit()}>Confirm</Button>
+    </div>
+  )
+}


Test patch:
diff --git a/allauth/account/tests/test_change_email.py b/allauth/account/tests/test_change_email.py
index 08428566..2b8e2ff9 100644
--- a/allauth/account/tests/test_change_email.py
+++ b/allauth/account/tests/test_change_email.py
@@ -309,15 +309,21 @@ def test_add_not_allowed(
         reverse("account_email"),
         {"action_add": "", "email": email},
     )
-    if prevent_enumeration == "strict":
+    if prevent_enumeration:
         assert resp.status_code == 302
-        EmailAddress.objects.get(
+        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 == {
diff --git a/allauth/account/tests/test_email_verification_by_code.py b/allauth/account/tests/test_email_verification_by_code.py
new file mode 100644
index 00000000..3581b97c
--- /dev/null
+++ b/allauth/account/tests/test_email_verification_by_code.py
@@ -0,0 +1,108 @@
+import re
+
+from django.contrib.auth import get_user_model
+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"
+    return settings
+
+
+def test_signup(client, db, settings, password_factory, get_last_code):
+    password = password_factory()
+    resp = client.post(
+        reverse("account_signup"),
+        {
+            "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"] == settings.LOGIN_REDIRECT_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
+    resp = auth_client.post(
+        reverse("account_email"), {"action_add": "", "email": email}
+    )
+    assert resp["location"] == reverse("account_email_verification_sent")
+    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
+    resp = auth_client.post(
+        reverse("account_email_verification_sent"), data={"code": code}
+    )
+    assert resp.status_code == 302
+    assert resp["location"] == reverse("account_email")
+    assert EmailAddress.objects.filter(email=email, verified=True).count() == 1
+    assert EmailAddress.objects.filter(user=user).count() == (1 if change_email else 2)