From 06e563b77adf2afbcad461e25ec0c27b2654ce1c Mon Sep 17 00:00:00 2001 From: Anatoly Date: Tue, 3 Sep 2019 16:48:06 +0300 Subject: [PATCH] refactored authorization app --- apps/account/models.py | 35 ++-- apps/account/serializers/web.py | 50 +++--- apps/account/views/common.py | 1 + apps/authorization/admin.py | 5 - .../0003_jwtaccesstoken_jwtrefreshtoken.py | 53 ++++++ .../0004_delete_blacklistedaccesstoken.py | 16 ++ apps/authorization/models.py | 151 ++++++++++++++---- apps/authorization/serializers/common.py | 62 ++----- apps/authorization/views/common.py | 14 +- apps/utils/exceptions.py | 2 +- apps/utils/permissions.py | 23 +-- apps/utils/tokens.py | 77 +++++++++ apps/utils/views.py | 4 +- 13 files changed, 345 insertions(+), 148 deletions(-) create mode 100644 apps/authorization/migrations/0003_jwtaccesstoken_jwtrefreshtoken.py create mode 100644 apps/authorization/migrations/0004_delete_blacklistedaccesstoken.py create mode 100644 apps/utils/tokens.py diff --git a/apps/account/models.py b/apps/account/models.py index 497fd188..5299773c 100644 --- a/apps/account/models.py +++ b/apps/account/models.py @@ -93,8 +93,10 @@ class User(ImageMixin, AbstractUser): self.is_active = switcher self.save() - def remove_token(self): - Token.objects.filter(user=self).delete() + def expire_access_token(self, jti): + access_token_qs = self.access_tokens.by_jti(jti=jti) + if access_token_qs.exists(): + access_token_qs.first().expire() def confirm_email(self): """Method to confirm user email address""" @@ -106,8 +108,19 @@ class User(ImageMixin, AbstractUser): self.is_active = True self.save() - def revoke_access_token(self): - print('Revoke token') + def get_body_email_message(self, subject: str, message: str): + """Prepare the body of the email message""" + return { + 'subject': subject, + 'message': str(message), + 'from_email': settings.EMAIL_HOST_USER, + 'recipient_list': [self.email, ] + } + + def send_email(self, subject: str, message: str): + """Send an email to reset user password""" + send_mail(**self.get_body_email_message(subject=subject, + message=message)) @property def confirm_email_token(self): @@ -149,20 +162,6 @@ class User(ImageMixin, AbstractUser): 'domain_uri': settings.DOMAIN_URI, 'site_name': settings.SITE_NAME}) - def get_body_email_message(self, subject: str, message: str): - """Prepare the body of the email message""" - return { - 'subject': subject, - 'message': str(message), - 'from_email': settings.EMAIL_HOST_USER, - 'recipient_list': [self.email, ] - } - - def send_email(self, subject: str, message: str): - """Send an email to reset user password""" - send_mail(**self.get_body_email_message(subject=subject, - message=message)) - class ResetPasswordTokenQuerySet(models.QuerySet): """Reset password token query set""" diff --git a/apps/account/serializers/web.py b/apps/account/serializers/web.py index 4b9b87f8..d4e7d1a8 100644 --- a/apps/account/serializers/web.py +++ b/apps/account/serializers/web.py @@ -1,13 +1,12 @@ """Serializers for account web""" from django.conf import settings from django.contrib.auth import password_validation as password_validators -from rest_framework_simplejwt import tokens from django.db.models import Q from rest_framework import serializers - -from account import models -from account import tasks +from django.utils import timezone +from account import models, tasks from utils import exceptions as utils_exceptions +from utils.tokens import GMRefreshToken class PasswordResetSerializer(serializers.ModelSerializer): @@ -180,27 +179,34 @@ class RefreshTokenSerializer(serializers.Serializer): def validate(self, attrs): """Override validate method""" - refresh_token = self.get_request().COOKIES.get('refresh_token') - if not refresh_token: + user = self.get_request().user + cookie_refresh_token = self.get_request().COOKIES.get('refresh_token') + + # Check if refresh_token in COOKIES + if not cookie_refresh_token: raise utils_exceptions.NotValidRefreshTokenError() - token = tokens.RefreshToken(token=refresh_token) + refresh_token = GMRefreshToken(cookie_refresh_token) + refresh_token_qs = user.refresh_tokens.valid()\ + .by_jti(refresh_token.payload.get('jti')) + # Check if the user has refresh token + if not refresh_token_qs.exists(): + raise utils_exceptions.NotValidRefreshTokenError() - data = {'access_token': str(token.access_token)} + # Expire existing refresh token + old_refresh_token = refresh_token_qs.first() + old_refresh_token.expire() - if settings.SIMPLE_JWT.get('ROTATE_REFRESH_TOKENS'): - if settings.SIMPLE_JWT.get('BLACKLIST_AFTER_ROTATION'): - try: - # Attempt to blacklist the given refresh token - token.blacklist() - except AttributeError: - # If blacklist app not installed, `blacklist` method will - # not be present - pass + # Expire existing access tokens + user.access_tokens.by_refresh_token_jti(jti=old_refresh_token.jti)\ + .valid()\ + .update(expires_at=timezone.now()) - token.set_jti() - token.set_exp() + # Create new one for user + refresh_token = GMRefreshToken.for_user(user) + refresh_token['user'] = user.get_user_info() - data['refresh_token'] = str(token) - - return data + return { + 'access_token': str(refresh_token.access_token), + 'refresh_token': str(refresh_token), + } diff --git a/apps/account/views/common.py b/apps/account/views/common.py index d7a422a2..5e5f8734 100644 --- a/apps/account/views/common.py +++ b/apps/account/views/common.py @@ -2,6 +2,7 @@ from fcm_django.models import FCMDevice from rest_framework import generics, status from rest_framework import permissions +from utils.permissions import IsAuthenticatedAndTokenIsValid from rest_framework.response import Response from account import models diff --git a/apps/authorization/admin.py b/apps/authorization/admin.py index eeb6f354..4d7fa8ea 100644 --- a/apps/authorization/admin.py +++ b/apps/authorization/admin.py @@ -1,7 +1,2 @@ from django.contrib import admin from authorization import models - - -@admin.register(models.BlacklistedAccessToken) -class BlacklistedAccessTokenAdmin(admin.ModelAdmin): - """Admin for BlackListedAccessToken""" diff --git a/apps/authorization/migrations/0003_jwtaccesstoken_jwtrefreshtoken.py b/apps/authorization/migrations/0003_jwtaccesstoken_jwtrefreshtoken.py new file mode 100644 index 00000000..73feee34 --- /dev/null +++ b/apps/authorization/migrations/0003_jwtaccesstoken_jwtrefreshtoken.py @@ -0,0 +1,53 @@ +# Generated by Django 2.2.4 on 2019-09-03 11:58 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('authorization', '0002_blacklistedaccesstoken'), + ] + + operations = [ + migrations.CreateModel( + name='JWTRefreshToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Date created')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('jti', models.CharField(max_length=255, unique=True)), + ('created_at', models.DateTimeField(blank=True, null=True)), + ('expires_at', models.DateTimeField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='refresh_tokens', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Refresh token', + 'verbose_name_plural': 'Refresh tokens', + 'unique_together': {('user', 'jti')}, + }, + ), + migrations.CreateModel( + name='JWTAccessToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Date created')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('source', models.PositiveSmallIntegerField(choices=[(0, 'Mobile'), (1, 'Web')], default=1, verbose_name='Source')), + ('created_at', models.DateTimeField(blank=True, null=True)), + ('expires_at', models.DateTimeField(verbose_name='Expiration datetime')), + ('jti', models.CharField(max_length=255, unique=True)), + ('refresh_token', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='access_tokens', to='authorization.JWTRefreshToken')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Access token', + 'verbose_name_plural': 'Access tokens', + 'unique_together': {('user', 'jti')}, + }, + ), + ] diff --git a/apps/authorization/migrations/0004_delete_blacklistedaccesstoken.py b/apps/authorization/migrations/0004_delete_blacklistedaccesstoken.py new file mode 100644 index 00000000..6f5f4997 --- /dev/null +++ b/apps/authorization/migrations/0004_delete_blacklistedaccesstoken.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2.4 on 2019-09-03 11:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('authorization', '0003_jwtaccesstoken_jwtrefreshtoken'), + ] + + operations = [ + migrations.DeleteModel( + name='BlacklistedAccessToken', + ), + ] diff --git a/apps/authorization/models.py b/apps/authorization/models.py index 4758788b..dd525df2 100644 --- a/apps/authorization/models.py +++ b/apps/authorization/models.py @@ -1,9 +1,13 @@ +from django.conf import settings from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from oauth2_provider import models as oauth2_models from oauth2_provider.models import AbstractApplication -from django.utils.translation import gettext_lazy as _ +from rest_framework_simplejwt import utils +from rest_framework_simplejwt.tokens import RefreshToken, AccessToken -from utils.models import PlatformMixin +from utils.models import PlatformMixin, ProjectBaseMixin # Create your models here. @@ -33,40 +37,133 @@ class Application(PlatformMixin, AbstractApplication): return (self.client_id,) -class BlacklistedAccessTokenQuerySet(models.QuerySet): - """Queryset for model BlacklistedAccessToken""" +class JWTAccessTokenManager(models.Manager): + """Manager for AccessToken model.""" + def add_to_db(self, user, access_token: AccessToken, refresh_token: RefreshToken): + """Create generated tokens to DB""" + refresh_token_qs = JWTRefreshToken.objects.filter(user=user, + jti=refresh_token.payload.get('jti')) + if refresh_token_qs.exists(): + jti = access_token[settings.SIMPLE_JWT.get('JTI_CLAIM')] + exp = access_token['exp'] + obj = self.model( + user=user, + jti=jti, + refresh_token=refresh_token_qs.first(), + created_at=access_token.current_time, + expires_at=utils.datetime_from_epoch(exp), + ) + obj.save() + return obj - def by_user(self, user): - """Filter by user""" - return self.filter(user=user) - def by_token(self, token): - """Filter by token""" - return self.filter(token=token) +class JWTAccessTokenQuerySet(models.QuerySet): + """QuerySets for AccessToken model.""" - def by_jti(self, jti): - """Filter by unique access_token identifier""" + def valid(self): + """Returns only valid access tokens""" + return self.filter(expires_at__gte=timezone.now()) + + def by_jti(self, jti: str): + """Filter by jti field""" + return self.filter(jti=jti) + + def by_refresh_token_jti(self, jti): + """Return all tokens by refresh token jti""" + return self.filter(refresh_token__jti=jti) + + +class JWTAccessToken(PlatformMixin, ProjectBaseMixin): + """GM access token model.""" + MOBILE = 0 + WEB = 1 + + SOURCES = ( + (MOBILE, _('Mobile')), + (WEB, _('Web')), + ) + + user = models.ForeignKey('account.User', + related_name='access_tokens', + on_delete=models.CASCADE) + source = models.PositiveSmallIntegerField(choices=SOURCES, default=WEB, + verbose_name=_('Source')) + refresh_token = models.ForeignKey('JWTRefreshToken', + related_name='access_tokens', + on_delete=models.DO_NOTHING) + created_at = models.DateTimeField(null=True, blank=True) + expires_at = models.DateTimeField(verbose_name=_('Expiration datetime')) + jti = models.CharField(unique=True, max_length=255) + + objects = JWTAccessTokenManager.from_queryset(JWTAccessTokenQuerySet)() + + class Meta: + """Meta class.""" + unique_together = ('user', 'jti') + verbose_name = _('Access token') + verbose_name_plural = _('Access tokens') + + def __str__(self): + """String representation method.""" + return f'Access token JTI: {self.jti}' + + def expire(self): + """Expire access token.""" + self.expires_at = timezone.now() + self.save() + + +class JWTRefreshTokenManager(models.Manager): + """Manager for model RefreshToken.""" + + def add_to_db(self, user, token: RefreshToken): + """Added generated refresh token to db""" + jti = token[settings.SIMPLE_JWT.get('JTI_CLAIM')] + exp = token['exp'] + obj = self.model( + user=user, + jti=jti, + created_at=token.current_time, + expires_at=utils.datetime_from_epoch(exp), + ) + obj.save() + return obj + + +class JWTRefreshTokenQuerySet(models.QuerySet): + """QuerySets for model RefreshToken.""" + + def valid(self): + """Return only balid refresh tokens""" + return self.filter(expires_at__gte=timezone.now()) + + def by_jti(self, jti: str): + """Filter by jti field""" return self.filter(jti=jti) -class BlacklistedAccessToken(models.Model): - +class JWTRefreshToken(ProjectBaseMixin): + """GM refresh token model.""" user = models.ForeignKey('account.User', - on_delete=models.CASCADE, - verbose_name=_('User')) + related_name='refresh_tokens', + on_delete=models.CASCADE) + jti = models.CharField(unique=True, max_length=255) + created_at = models.DateTimeField(null=True, blank=True) + expires_at = models.DateTimeField() - jti = models.CharField(max_length=255, unique=True, - verbose_name=_('Unique access_token identifier')) - token = models.TextField(verbose_name=_('Access token')) - - blacklisted_at = models.DateTimeField(auto_now_add=True, - verbose_name=_('Blacklisted datetime')) - - objects = BlacklistedAccessTokenQuerySet.as_manager() + objects = JWTRefreshTokenManager.from_queryset(JWTRefreshTokenQuerySet)() class Meta: - """Meta class""" - unique_together = ('token', 'user') + """Meta class.""" + unique_together = ('user', 'jti') + verbose_name = _('Refresh token') + verbose_name_plural = _('Refresh tokens') def __str__(self): - return 'Blacklisted access token for {}'.format(self.user) + """String representation method.""" + return f'Refresh token JTI: {self.jti}' + + def expire(self): + """Expire refresh token.""" + self.expires_at = timezone.now() + self.save() diff --git a/apps/authorization/serializers/common.py b/apps/authorization/serializers/common.py index d889ad78..d764eeca 100644 --- a/apps/authorization/serializers/common.py +++ b/apps/authorization/serializers/common.py @@ -5,14 +5,13 @@ from django.contrib.auth import password_validation as password_validators from django.db.models import Q from rest_framework import serializers from rest_framework import validators as rest_validators -from authorization import tasks -# JWT -from rest_framework_simplejwt import tokens from account import models as account_models -from authorization.models import Application, BlacklistedAccessToken +from authorization import tasks +from authorization.models import Application from utils import exceptions as utils_exceptions from utils import methods as utils_methods +from utils import tokens # Mixins @@ -30,18 +29,21 @@ class JWTBaseSerializerMixin(serializers.Serializer): refresh_token = serializers.CharField(read_only=True) access_token = serializers.CharField(read_only=True) - def get_token(self): + def get_tokens(self): """Create JWT token""" user = self.instance - token = tokens.RefreshToken.for_user(user) + token = tokens.GMRefreshToken.for_user(user) token['user'] = user.get_user_info() - return token + return { + 'access_token': str(token.access_token), + 'refresh_token': str(token), + } def to_representation(self, instance): """Override to_representation method""" - token = self.get_token() - setattr(instance, 'access_token', str(token.access_token)) - setattr(instance, 'refresh_token', str(token)) + tokens = self.get_tokens() + setattr(instance, 'access_token', tokens.get('access_token')) + setattr(instance, 'refresh_token', tokens.get('refresh_token')) return super().to_representation(instance) @@ -136,46 +138,6 @@ class LoginByUsernameOrEmailSerializer(JWTBaseSerializerMixin, serializers.Model self.instance = user return attrs - def to_representation(self, instance): - """Override to_representation method""" - token = self.get_token() - setattr(instance, 'access_token', str(token.access_token)) - setattr(instance, 'refresh_token', str(token)) - # setattr(instance, 'remember', self.validated_data.get('remember')) - return super().to_representation(instance) - - -class LogoutSerializer(serializers.ModelSerializer): - """Serializer class for model Logout""" - - class Meta: - model = BlacklistedAccessToken - fields = ( - 'user', - 'token', - 'jti' - ) - read_only_fields = ( - 'user', - 'token', - 'jti' - ) - - def create(self, validated_data): - """Override create method""" - request = self.context.get('request') - # Get token bytes from cookies (result: b'Bearer ') - token_bytes = utils_methods.get_token_from_cookies(request) - # Get token value from bytes - token = token_bytes.decode().split(' ')[::-1][0] - # Get access token obj - access_token = tokens.AccessToken(token) - # Prepare validated data - validated_data['user'] = request.user - validated_data['token'] = access_token.token - validated_data['jti'] = access_token.payload.get('jti') - return super().create(validated_data) - # OAuth class OAuth2Serialzier(BaseAuthSerializerMixin): diff --git a/apps/authorization/views/common.py b/apps/authorization/views/common.py index 598cb04e..a7384b30 100644 --- a/apps/authorization/views/common.py +++ b/apps/authorization/views/common.py @@ -13,15 +13,16 @@ from rest_framework import generics from rest_framework import permissions from rest_framework import status from rest_framework.response import Response -from rest_framework_simplejwt import tokens as jwt_tokens +from rest_framework_simplejwt.tokens import AccessToken from rest_framework_social_oauth2.oauth2_backends import KeepRequestCore from rest_framework_social_oauth2.oauth2_endpoints import SocialTokenServer -from utils.models import GMTokenGenerator from account.models import User from authorization.models import Application from authorization.serializers import common as serializers from utils import exceptions as utils_exceptions +from utils import tokens as jwt_tokens +from utils.models import GMTokenGenerator from utils.views import (JWTGenericViewMixin, JWTCreateAPIView) @@ -109,7 +110,7 @@ class OAuth2SignUpView(OAuth2ViewMixin, JWTGenericViewMixin): def get_jwt_token(self, user: User): """Get JWT token""" - token = jwt_tokens.RefreshToken.for_user(user) + token = jwt_tokens.GMRefreshToken.for_user(user) # Adding additional information about user to payload token['user'] = user.get_user_info() return token @@ -218,11 +219,10 @@ class LoginByUsernameOrEmailView(JWTAuthViewMixin): # Logout class LogoutView(JWTGenericViewMixin): """Logout user""" - serializer_class = serializers.LogoutSerializer def post(self, request, *args, **kwargs): """Override create method""" - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - serializer.save() + access_token = request.COOKIES.get('access_token') + access_token_obj = AccessToken(access_token) + request.user.expire_access_token(jti=access_token_obj.payload.get('jti')) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/utils/exceptions.py b/apps/utils/exceptions.py index 7a7bc94e..f8d81366 100644 --- a/apps/utils/exceptions.py +++ b/apps/utils/exceptions.py @@ -87,7 +87,7 @@ class NotValidAccessTokenError(exceptions.APIException): class NotValidRefreshTokenError(exceptions.APIException): """The exception should be thrown when refresh token is not valid """ - status_code = status.HTTP_400_BAD_REQUEST + status_code = status.HTTP_401_UNAUTHORIZED default_detail = _('Not valid refresh token') diff --git a/apps/utils/permissions.py b/apps/utils/permissions.py index d8510e17..9731f1b6 100644 --- a/apps/utils/permissions.py +++ b/apps/utils/permissions.py @@ -1,10 +1,7 @@ """Project custom permissions""" from rest_framework.permissions import BasePermission -from rest_framework_simplejwt.exceptions import TokenBackendError -from authorization.models import BlacklistedAccessToken -from utils.exceptions import NotValidAccessTokenError -from utils.methods import get_token_from_cookies +from rest_framework_simplejwt.tokens import AccessToken class IsAuthenticatedAndTokenIsValid(BasePermission): @@ -15,17 +12,11 @@ class IsAuthenticatedAndTokenIsValid(BasePermission): def has_permission(self, request, view): """Check permissions by access token and default REST permission IsAuthenticated""" user = request.user - try: - if user and user.is_authenticated: - token_bytes = get_token_from_cookies(request) - # Get access token key - token = token_bytes.decode().split(' ')[1] - # Check if user access token not expired - blacklisted = BlacklistedAccessToken.objects.by_token(token) \ - .by_user(user) \ - .exists() - return not blacklisted - except TokenBackendError: - raise NotValidAccessTokenError() + access_token = request.COOKIES.get('access_token') + if user.is_authenticated and access_token: + access_token = AccessToken(access_token) + valid_tokens = user.access_tokens.valid()\ + .by_jti(jti=access_token.payload.get('jti')) + return valid_tokens.exists() else: return False diff --git a/apps/utils/tokens.py b/apps/utils/tokens.py new file mode 100644 index 00000000..5d8776b2 --- /dev/null +++ b/apps/utils/tokens.py @@ -0,0 +1,77 @@ +"""Custom tokens based on django-rest-framework-simple-jwt""" +from rest_framework_simplejwt.settings import api_settings +from rest_framework_simplejwt.tokens import Token, AccessToken, RefreshToken, BlacklistMixin + +from account.models import User +from authorization.models import JWTRefreshToken, JWTAccessToken + + +class GMToken(Token): + """Custom JWT Token class""" + + @classmethod + def for_user(cls, user): + """ + Returns an authorization token for the given user that will be provided + after authenticating the user's credentials. + """ + user_id = getattr(user, api_settings.USER_ID_FIELD) + if not isinstance(user_id, int): + user_id = str(user_id) + + token = cls() + token[api_settings.USER_ID_CLAIM] = user_id + + return token + + +class GMBlacklistMixin(BlacklistMixin): + """ + If the `rest_framework_simplejwt.token_blacklist` app was configured to be + used, tokens created from `BlacklistMixin` subclasses will insert + themselves into an outstanding token list and also check for their + membership in a token blacklist. + """ + + @classmethod + def for_user(cls, user): + """ + Adds this token to the outstanding token list. + """ + token = super().for_user(user) + # Create a record in DB + JWTRefreshToken.objects.add_to_db(user=user, token=token) + return token + + +class GMRefreshToken(GMBlacklistMixin, GMToken, RefreshToken): + """GM refresh token""" + + @property + def access_token(self): + """ + Returns an access token created from this refresh token. Copies all + claims present in this refresh token to the new access token except + those claims listed in the `no_copy_claims` attribute. + """ + access_token = AccessToken() + + # Use instantiation time of refresh token as relative timestamp for + # access token "exp" claim. This ensures that both a refresh and + # access token expire relative to the same time if they are created as + # a pair. + access_token.set_exp(from_time=self.current_time) + + no_copy = self.no_copy_claims + for claim, value in self.payload.items(): + if claim in no_copy: + continue + access_token[claim] = value + + # Create a record in DB + user = User.objects.get(id=self.payload.get('user_id')) + JWTAccessToken.objects.add_to_db(user=user, + access_token=access_token, + refresh_token=self) + return access_token + diff --git a/apps/utils/views.py b/apps/utils/views.py index 60c18369..c89812b6 100644 --- a/apps/utils/views.py +++ b/apps/utils/views.py @@ -4,7 +4,7 @@ from django.conf import settings from rest_framework import generics from rest_framework import status from rest_framework.response import Response -from rest_framework_simplejwt import tokens +from utils import tokens # JWT @@ -21,7 +21,7 @@ class JWTGenericViewMixin(generics.GenericAPIView): def _create_jwt_token(self, user) -> dict: """Return dictionary with pairs access and refresh tokens""" - token = tokens.RefreshToken.for_user(user) + token = tokens.GMRefreshToken.for_user(user) token['user'] = user.get_user_info() return { 'access_token': str(token.access_token),