djoser icon indicating copy to clipboard operation
djoser copied to clipboard

Problem using social auth in stateless webapp

Open Chadys opened this issue 4 years ago • 19 comments

I have the same problem as here, meaning, the last step of Google OAuth2 authentication is not working. After some searching, I saw that the problem comes from the validation of state : the value in the request is checked against the value from the previous request that was saved in session. My problem, and I suppose it is the same one as @Emnalyeriar's, is that my app is stateless, I don't use session nor cookies so getting previous value of state is impossible, nor is it restful. djoser main target are stateless apps, not being able to use the OAuth2 protocol (which is the standard for most providers) make social auth unusable. Any use of session should therefore be removed. What do you think ?

Chadys avatar Jul 08 '19 15:07 Chadys

As I see, there are two groups of people - those that use djoser with sessions and those that don't. We need to support both but that has to be done The Right Way™ :thinking: Any suggestions welcome.

dekoza avatar Jul 26 '19 10:07 dekoza

I would add session auth as own auth method. And, try to separate further, ie not use sessions at all in other auth methods, if possible? a test suite that disables sessions for some cases would ensure this stays that way.

benzkji avatar Jul 28 '19 16:07 benzkji

@Chadys Is there any current workaround you're using to combat this issue?

joshm91 avatar Aug 30 '19 03:08 joshm91

@joshm91 nope, sadly just not using social auth for now. Ps: sorry for the open/close, missclick

Chadys avatar Aug 30 '19 07:08 Chadys

Damn, that's a shame. I was hoping to switch from django-rest-auth to djoser for a complete restful local account and social solution. I'm currently trying to get django-rest-social-auth working for the social side of things but it sucks having to use multiple different libraries.

joshm91 avatar Aug 30 '19 08:08 joshm91

In fact, django-rest-social-auth uses social-django as well, they just basically negate all of the session stuff in a custom strategy

joshm91 avatar Aug 30 '19 08:08 joshm91

If anyone's interested I created my own social login views with requests_oauthlib:

class GoogleOAuth2(APIView):
    """
    Login with Google OAuth2
    """

    def get(self, request):
        client_id = settings.GOOGLE_OAUTH2_KEY
        scope = settings.GOOGLE_OAUTH2_SCOPE
        redirect_uri = request.query_params.get('redirect_uri')
        if redirect_uri not in settings.SOCIAL_AUTH_ALLOWED_REDIRECT_URIS:
            return response.Response(
                {
                    'error': 'Wrong Redirect URI'
                },
                status=status.HTTP_400_BAD_REQUEST,
            )
        google = OAuth2Session(client_id, scope=scope, redirect_uri=redirect_uri)
        authorization_url, state = google.authorization_url(
            settings.GOOGLE_AUTHORIZATION_BASE_URL,
            access_type='offline',
            prompt='select_account'
        )
        return response.Response({'authorization_url': authorization_url})

    def post(self, request):

        client_id = settings.GOOGLE_OAUTH2_KEY
        client_secret = settings.GOOGLE_OAUTH2_SECRET

        state = request.data.get('state')
        code = request.data.get('code')
        redirect_uri = request.data.get('redirect_uri')

        google = OAuth2Session(
            client_id,
            redirect_uri=redirect_uri,
            state=state
        )
        google.fetch_token(
            settings.GOOGLE_TOKEN_URL,
            client_secret=client_secret,
            code=code
        )

        user_info = google.get('https://www.googleapis.com/oauth2/v1/userinfo').json()
        user_email = user_info['email']
        try:
            user = User.objects.get(email=user_email)
        except User.DoesNotExist:
            # Decide if you want to create a new user
            user = User.objects.create_user()
        refresh_token = RefreshToken.for_user(user)
        return response.Response({
            'refresh': str(refresh_token),
            'access': str(refresh_token.access_token)
        })

I also have one for FB but its very similar

Emnalyeriar avatar Aug 30 '19 09:08 Emnalyeriar

I think the code above by @Emnalyeriar is not really secure: It basically does not check if the state in the POST matches the state generated in the GET:

  • state in GET is stored as variable, but never used again, the OAuth session is discarded after the request - which is not the case in the requests_oauthlib examples
  • in the POST a new OAuth session is initialized simply with the state passed in the request - ignoring the state value that was generated in the GET

As I understand this allows CSRF attacks, the implementation should check if both states are the same. See for example the Github OAuth docs on state:

Bildschirmfoto 2020-05-03 um 15 59 26

I'm not sure what a correct solution here is. Obviously sessions work which is what the Django social auth lib uses. Maybe it is sufficient to set the state as a cookie in the GET response which can be used to initialize state in the OAuth session in the POST - but I'm far from an expert in this matter.

Edit: After looking some more into the matter, the cookie approach will only work if you use same-site cookies. That in turn will only work if your frontend is hosted on the same domain as your REST API.

If your frontend is hosted on another domain, it should be a valid approach to:

  • issue a signed CSRF token from your REST API in the GET, the token containing the state value
  • store the token in the browser in the frontend domain (local storage or cookie)
  • pass the CSRF token in the POST request to the API, decode and verify it's signature to make sure it was issued by your API
  • use the decoded state to initialize the OAuth session and pass in the complete authorization callback URL including code and state to make OAuth session verify that the states match. Alternatively compare states manually
  • the token should also contain a timestamp that is validated at this point to protect against replay attacks: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#encryption-based-token-pattern

sissbruecker avatar May 03 '20 14:05 sissbruecker

Any update on this?

adriangzz avatar Jul 01 '20 22:07 adriangzz

hi all, I may need social auth on djoser to work with JWT (stateless). Do I understand correctly that it can be done with judicious use of HttpOnly cookies and CSRF protection (which Django natively supports)?

kakarukeys avatar Aug 11 '20 09:08 kakarukeys

@kakarukeys yours is another question - please open a new issue.

benzkji avatar Aug 11 '20 09:08 benzkji

@kakarukeys yours is another question - please open a new issue.

No. I have the same setup with @Chadys. I don't use session / cookies. I need to enable login with Google & FB OAuth2. I haven't done so, but I anticipated running into the same problem as @Chadys if I follow djoser's documentation. Is it not the case?

kakarukeys avatar Aug 11 '20 10:08 kakarukeys

@kakarukeys I ended changing to dj-rest-auth, it's the active fork of django-rest-auth. Djoser doesn't seem to be as active as before.

adriangzz avatar Aug 12 '20 22:08 adriangzz

@kakarukeys I ended changing to dj-rest-auth, it's the active fork of django-rest-auth. Djoser doesn't seem to be as active as before.

i see. I was thinking of fixing it and sending a pull request. just need abit of confirmation of what goes behind the scene.

kakarukeys avatar Aug 13 '20 10:08 kakarukeys

I managed to get social login working using knox tokens (no sessions) with the help of django-rest-social-auth:

urlpatterns = [
    path(
        "login/<str:provider>/",
        djoser.social.views.ProviderAuthView.as_view(http_method_names=["get"]),
        name="begin",
    ),
    path(
        "complete/<str:provider>/",
        rest_social_auth.views.SocialKnoxOnlyAuthView.as_view(http_method_names=["post"]),
        name="complete",
    ),
]

However, I got stuck again implementing disconnecting social accounts. django-rest-social-auth does not seem to do anything for disconnecting social accounts!

akhayyat avatar Sep 10 '21 17:09 akhayyat

I got disconnecting social accounts to work by looking at how django-rest-social-auth did the authentication, and ended up with the following working implementation:

# urls.py
urlpatterns = [
    ...
    path(
        "disconnect/<str:provider>/",
        views.DisconnectView.as_view(http_method_names=["post"]),
        name="disconnect",
    ),
    path(
        "disconnect/<str:provider>/<int:association_id>/",
        views.DisconnectView.as_view(http_method_names=["post"]),
        name="disconnect_individual",
    ),
]
# views.py
from rest_social_auth.views import decorate_request
from social_core.actions import do_disconnect

class DisconnectView(APIView):
    @method_decorator(never_cache)
    def post(self, request, *args, **kwargs):
        """Disconnects given backend from current logged in user."""
        provider = kwargs["provider"]
        decorate_request(request, provider)
        association_id = kwargs.pop("association_id", None)

        try:
            do_disconnect(
                request.backend,
                request.user,
                association_id,
                redirect_name=REDIRECT_FIELD_NAME,
            )
        except NotAllowedToDisconnect:
            raise APIException(
                "You cannot disconnect this social account since you don't have another "
                "way to login to your account. Either associate another social account "
                "or set a password on your account to disconnect this social account.",
            )
        else:
            return Response(None, status=status.HTTP_204_NO_CONTENT)

Lastly, with all of the above, there is one catch: knox tokens issued for the social account remain valid after disconnecting and removing it. A kind-of brute-forcy way to fix that is to add the following function at the end of SOCIAL_AUTH_DISCONNECT_PIPELINE:

# pipeline.py
def delete_tokens(user, *args, **kwargs):
    """
    Delete all tokens and log out all user sessions.

    Currently, knox tokens are only associated with users, and not with social logins, so
    we can't delete only the social auth token. To ensure the social auth token is
    expired, we simply delete all tokens of the current user.
    """
    user.auth_token_set.all().delete()
    user_logged_out.send(sender=user.__class__, user=user)

Hopefully someone can find this useful. Better yet, if anyone can share better ways to do this, or perhaps have djoser provide better support for these scenarios.

akhayyat avatar Sep 11 '21 20:09 akhayyat

So I managed to get social authentication to work with knox tokens without session authentication and without django-rest-social-auth as follows:

# urls.py
    ...
    path("complete/<str:provider>/", views.CompleteView.as_view(), name="complete"),
    ...
# views.py
@psa()
def _psa(request, backend):
    pass

class CompleteView(knox.views.LoginView):
    permission_classes = [rest_framework.permissions.AllowAny]

    @method_decorator(never_cache)
    def post(self, request, *args, **kwargs):
        provider = kwargs["provider"]
        _psa(request, provider)
        request.backend.data = request.data
        request.backend.redirect_uri = request.data.get("redirect_uri")
        request.backend.REDIRECT_STATE = False
        request.backend.STATE_PARAMETER = False
        user = request.user if request.user.is_authenticated else None
        user = request.backend.complete(user=user)
        if not isinstance(user, User):
            return user

        request.user = user
        return super().post(request)

This seems to work pretty well for oauth v1 and v2.

akhayyat avatar Oct 03 '21 22:10 akhayyat

First of all, I haven't wrapped my head around the alternative libraries:

From a short investigation I concluded they do not solve the problem out of the box (without any custom code). Probably, I'm wrong cause it was a really quick overview.

So I ended up with some custom code for Djoser. The first portion of patching is actually regarding PSA: let's deal with state, here's the overridden Instagram back end:

class InstagramRequestState(InstagramOAuth2):

    REDIRECT_STATE = False

    def get_or_create_state(self):
        token = super().get_or_create_state()
        signer = signing.TimestampSigner(salt=self.name)
        return signer.sign(token)

    def validate_state(self):
        if not self.STATE_PARAMETER and not self.REDIRECT_STATE:
            return None
        request_state = self.get_request_state()
        if not request_state:
            raise AuthMissingParameter(self, 'state')

        signer = signing.TimestampSigner(salt=self.name)
        try:
            token = signer.unsign(request_state, max_age=300)
        except (signing.BadSignature, signing.SignatureExpired) as e:
            raise AuthStateMissing(self, 'state') from e

        state = self.get_session_state()
        if state and not constant_time_compare(state, token):
            raise AuthStateForbidden(self)

        return request_state

The validate_state method is copied almost fully, but with some adaptations.

The basic idea is that we're saving to the session (cookie-based by default) the same state, BUT! We really rely on the request_state which is passed to the query string value of the redirect_uri provided. It's signed by the built-in Django means, using Django secret as a private key. It guaranties for us that the request_state was generated on our side, not a malicious 3-rd party side. This works perfect for a stateless case.

For a stateful case we have full compatibility with an additional check of the signed token which is stored as a state in the session.

theoden-dd avatar Apr 28 '22 19:04 theoden-dd

Another thing that is worth mentioning: I had to patch Djoser ProviderAuthSerializer to pass-through request and logged in user.

Just change the call to auth_complete in its validate method:

            user = backend.auth_complete(request=request, user=request.user)

This helps to properly handle cases when we'd like to link social user to the current user, not to create a brand new one. Passing request helps not to break some authentication back ends which require it. For example, axes.

I'm not sure this works with all the PSA strategies (e.g. Flask), however it certainly works for Django. Hope this helps.

theoden-dd avatar Apr 28 '22 19:04 theoden-dd