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

[Placeholder] Implement "magic" login code #661

Open
dontic opened this issue Oct 22, 2024 · 9 comments
Open

[Placeholder] Implement "magic" login code #661

dontic opened this issue Oct 22, 2024 · 9 comments

Comments

@dontic
Copy link
Contributor

dontic commented Oct 22, 2024

Earlier this year allauth released a code-based login method.

  1. User requests login by inputting their email address
  2. Frontend sends request to dj-rest-auth's endpoint /login/code/
  3. A session gets generated (similar to the email verification flow)
  4. User receives an email with a random 6 character code (as per allauth)
  5. User inputs that code in the frontend.
  6. Frontend sends request to dj-rest-auth's endpoint /login/code/confirm/
  7. Backend logs the user in (or not)

This issue is a placeholder to keep track of the progress and discussion

Planning to create a pull request for this as soon as I can.

@dontic dontic changed the title [Placeholder] Implement magic code [Placeholder] Implement "magic" login code Oct 22, 2024
@dontic
Copy link
Contributor Author

dontic commented Oct 23, 2024

@iMerica I've tried to implement this in dj-rest-auth directly but it is not as simple as I anticipated. In my current project I'm only using session auth with a mandatory email, but implementing this functionality for all types of auth escapes my expertise in the dj-rest-auth project. If I have more time in the following weeks I will still try to implement this.

P.S.: I wouldn't mind receiving guidance as to what's needed at high level to implement this in the project (i.e.: references in the code, what checks need to happen and where they should be - serializers vs. views) I can think of a initial list:

  • If ACCOUNT_LOGIN_BY_CODE_ENABLED enable these endpoints and add checks for ACCOUNT_EMAIL_REQUIRED
  • If ACCOUNT_LOGIN_BY_CODE_REQUIRED disable the other login endpoint

Here's the scrappy implementation I've done on my side, which is currently working and might be useful if anyone wants to have a go at it:

⚠️ Note that I'm not even sure if this implementation is even secure! Use at your own risk.

# serializers.py

class RequestLoginCodeSerializer(serializers.Serializer):
    """
    Serializer to validate the email address when requesting a login code.
    """

    email = serializers.EmailField(required=True)

    def validate_email(self, email):
        email = get_adapter().clean_email(email)

        if not EmailAddress.objects.filter(email__iexact=email).exists():
            raise serializers.ValidationError(
                _("No user is registered with this e-mail address.")
            )
        return email


class VerifyLoginCodeSerializer(serializers.Serializer):
    code = serializers.CharField(required=True)
# views.py

class RequestLoginCodeView(GenericAPIView):
    serializer_class = RequestLoginCodeSerializer
    permission_classes = (AllowAny,)
    throttle_scope = "dj_rest_auth"

    def post(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)

        # Do not raise exception if the serializer is invalid
        # This is to prevent users from knowing if an email is registered
        if serializer.is_valid(raise_exception=False):
            email = serializer.validated_data["email"]
            flows.login_by_code.request_login_code(request, email)

        # The response is the same regardless of whether the email is registered
        return Response(
            {"detail": _("If the email is registered, a login code will be sent.")},
            status=status.HTTP_200_OK,
        )


class VerifyLoginCodeView(GenericAPIView):
    serializer_class = VerifyLoginCodeSerializer
    permission_classes = (AllowAny,)
    throttle_scope = "dj_rest_auth"

    def post(self, request, *args, **kwargs):

        # Get the code from the serializer
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        actual_code = serializer.validated_data["code"]

        # If the user is authenticated, return a 400
        if request.user.is_authenticated:
            return Response(
                {"detail": _("You are already logged in.")},
                status=status.HTTP_400_BAD_REQUEST,
            )

        # Get the login stage
        login_stage = LoginStageController.enter(request, LoginByCodeStage.key)
        if not login_stage:
            return Response(
                {"detail": _("The login code is invalid. Please request a new one.")},
                status=status.HTTP_400_BAD_REQUEST,
            )

        user, pending_login = flows.login_by_code.get_pending_login(
            self.request, login_stage.login, peek=True
        )
        if not pending_login:
            return Response(
                {"detail": _("The login code is invalid. Please request a new one.")},
                status=status.HTTP_400_BAD_REQUEST,
            )

        # Get the expected code from the pending session
        expected_code = pending_login.get("code", "")
        if not expected_code:
            return Response(
                {"detail": _("The login code is invalid. Please request a new one.")},
                status=status.HTTP_400_BAD_REQUEST,
            )

        # Check if the code is valid
        if not flows.login_by_code.compare_code(
            actual=actual_code, expected=expected_code
        ):
            # Record an invalid attempt
            flows.login_by_code.record_invalid_attempt(request, login_stage.login)
            return Response(
                {
                    "detail": _(
                        "The login code is invalid. Please input the correct code."
                    )
                },
                status=status.HTTP_400_BAD_REQUEST,
            )

        # Validate that the user is active
        if not user.is_active:
            return Response(
                {"detail": _("The user account is disabled.")},
                status=status.HTTP_400_BAD_REQUEST,
            )

        # Validate that the user has verified their email if required
        from allauth.account import app_settings as allauth_account_settings

        if (
            allauth_account_settings.EMAIL_VERIFICATION
            == allauth_account_settings.EmailVerificationMethod.MANDATORY
            and not user.emailaddress_set.filter(
                email=user.email, verified=True
            ).exists()
        ):
            return Response(
                {"detail": _("The user account has not been verified.")},
                status=status.HTTP_400_BAD_REQUEST,
            )

        # Perform the login
        flows.login_by_code.perform_login_by_code(request, login_stage, None)

        # Return successful response
        return Response(
            {"detail": _("Ok")},
            status=status.HTTP_200_OK,
        )
# settings.py

# ---------------------------------- ALLAUTH --------------------------------- #

# https://docs.allauth.org/en/latest/index.html

ACCOUNT_AUTHENTICATION_METHOD = "email"  # username, email or username_email
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_EMAIL_REQUIRED = True  # Needs to be True to use email as the auth method
ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_EMAIL_VERIFICATION = "mandatory"  # "none", "optional", "mandatory"
ACCOUNT_LOGIN_BY_CODE_ENABLED = True  # Enable login by Magic Code
# ACCOUNT_CONFIRM_EMAIL_ON_GET = True
# ACCOUNT_EMAIL_NOTIFICATIONS = True  # This does not work yet with dj-rest-auth, it's handled in authentication.signals
SOCIALACCOUNT_QUERY_EMAIL = True  # Needed to set email as the username
SOCIALACCOUNT_LOGIN_ON_GET = True  # Needed to login with social accounts
SOCIALACCOUNT_STORE_TOKENS = True  # Needed to access Provider's APIs
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = (
    False  # Do not allow unknown accounts to reset password
)
ACCOUNT_ADAPTER = "authentication.adapter.CustomAccountAdapter"  # Override the default adapter to use a custom email module
CUSTOM_ACCOUNT_CONFIRM_EMAIL_URL = (
    "/verifyemail/?key={0}"  # An email verification URL that the client will pick up.
)

# Automatically log the user in after email confirmation
# This only works if the confirmation happens in the same browser
ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True

# Enable login by code
ACCOUNT_LOGIN_BY_CODE_ENABLED = True
ACCOUNT_LOGIN_BY_CODE_REQUIRED = False
ACCOUNT_LOGIN_BY_CODE_TIMEOUT = 60 * 15  # 15 minutes
ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS = 5

# allauth providers
# https://docs.allauth.org/en/latest/socialaccount/provider_configuration.html
SOCIALACCOUNT_PROVIDERS = {}

# Site ID is needed for allauth
SITE_ID = 1


# ------------------------------- DJ-REST-AUTH ------------------------------- #

# See defaults in https://dj-rest-auth.readthedocs.io/en/latest/configuration.html
REST_AUTH = {
    # Use sessions instead of tokens
    "TOKEN_MODEL": None,
    "TOKEN_SERIALIZER": None,
    "SESSION_LOGIN": True,
    "LOGIN_SERIALIZER": "authentication.serializers.LoginSerializer",
    "REGISTER_SERIALIZER": "authentication.serializers.RegisterSerializer",
    "USER_DETAILS_SERIALIZER": "authentication.serializers.CustomUserDetailsSerializer",
    "OLD_PASSWORD_FIELD_ENABLED": True,
}

@pennersr
Copy link

@dontic I am curious, why not just use allauth.headless which has builtin support for login by code?

@dontic
Copy link
Contributor Author

dontic commented Oct 24, 2024

@dontic I am curious, why not just use allauth.headless which has builtin support for login by code?

Cause I didn't even know that was a even a thing 🤦‍♂️.

This is great, reading the docs now.

@dontic
Copy link
Contributor Author

dontic commented Oct 24, 2024

@pennersr just read the thing, this is amazing work.

A couple of things to take into consideration:

  1. The most important to me is that the headless implementation doesn't seem to be really integrated into the REST framework.

    This means that apps like drf-spectacular don't register the endpoints. While I could indeed create 2 separate API layers in my client app (i.e.: React) it's really convenient having everything show up in the API spec and using drf-spectacular and creating the whole API layer with a single command in the React app with orval.

    Another pain-point is that when creating a DRF project, debugging via the swagger UI of drf-spectacular is REALLY convenient. Again, this could be done by getting the session cookie from the frontend and then authorizing in drf-spectacular, but I do need to code a frontend for that first.

    After a bit of investigation this seems to also be a pain point for other people: drf-spectacular and allauth tfranzel/drf-spectacular#1220

    Do you think it would be possible to integrate DRF into the headless portion of django-allauth? I.e.: When headless is used, prompt people to install DRF. I would be willing to help with PRs and docs as needed.

  2. Another minor thing I like about dj-rest-auth is that I can override the serializers, i.e.:

    "REGISTER_SERIALIZER": "authentication.serializers.RegisterSerializer",

    This is important since, for instance, I have a tenancy implementation and I need to create the tenant or assign a user to a tenant (via an invitation code) when a user is signing up.

    This is not that bad though, since there are workarounds, for instance it could be done in separate API calls, with checks on the tenancy side to ensure the user gets created successfully before creating the tenant. Then having some safeguards in place to role back the user creation if the tenant cannot be created successfully.

We can also perhaps move this discussion elsewhere where it's more relevant - should we open an issue in https://github.com/pennersr/django-allauth and continue there?

@pennersr
Copy link

  1. django-allauth won't be picking sides in the DRF vs Ninja debate, hence, it won't become dependent on either one of them.

  2. Requiring additional fields during signup is supported, just create a ACCOUNT_SIGNUP_FORM_CLASS form, deriving from forms.Form, with only the extra fields you require, and you will be able to POST these fields to the headless endpoint as well.

@dontic
Copy link
Contributor Author

dontic commented Oct 24, 2024

  1. django-allauth won't be picking sides in the DRF vs Ninja debate, hence, it won't become dependent on either one of them.

Makes total sense, didn't think of the Ninja guys. Keeping it agnostic is the way to go for sure.

  1. Requiring additional fields during signup is supported, just create a ACCOUNT_SIGNUP_FORM_CLASS form, deriving from forms.Form, with only the extra fields you require, and you will be able to POST these fields to the headless endpoint as well

Gotcha, thanks for the help!!

@olegasics
Copy link

Hello everyone. Am I correct in understanding that login by code in allauth is only available through Django forms? If I want to implement it through a REST API, I need to write my own viewsets and serializers similar to those @dontic shared above. If that’s the case, is there perhaps a need to add this functionality to dj-rest-auth? I could submit a pull request. My project currently requires this functionality, and I have implemented my own solution for it.

@pennersr
Copy link

pennersr commented Nov 2, 2024

@olegasics

Am I correct in understanding that login by code in allauth is only available through Django forms?

No, there is a REST API for that:

https://docs.allauth.org/en/latest/headless/openapi-specification/#tag/Authentication:-Login-By-Code/paths/~1_allauth~1%7Bclient%7D~1v1~1auth~1code~1request/post

@olegasics
Copy link

@pennersr thank you so much for this url!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants