djoser
djoser copied to clipboard
Problem using social auth in stateless webapp
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 ?
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.
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.
@Chadys Is there any current workaround you're using to combat this issue?
@joshm91 nope, sadly just not using social auth for now. Ps: sorry for the open/close, missclick
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.
In fact, django-rest-social-auth uses social-django as well, they just basically negate all of the session stuff in a custom strategy
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
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 thestate
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](https://user-images.githubusercontent.com/357820/80916752-f843fe00-8d5a-11ea-9e7e-c10e7386909c.png)
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
Any update on this?
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 yours is another question - please open a new issue.
@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 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.
@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.
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!
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.
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.
First of all, I haven't wrapped my head around the alternative libraries:
- django-rest-social-auth, (see the same state problem as above)
- drf-social-oauth2.
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.
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.