API - revoke all token for a given client
This commit is contained in:
		@@ -93,3 +93,15 @@ class OAuth2Token(BaseModel, OAuth2TokenMixin):
 | 
				
			|||||||
            return False
 | 
					            return False
 | 
				
			||||||
        expires_at = self.issued_at + self.expires_in * 2
 | 
					        expires_at = self.issued_at + self.expires_in * 2
 | 
				
			||||||
        return expires_at >= time.time()
 | 
					        return expires_at >= time.time()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def revoke_client_tokens(cls, client_id: str) -> None:
 | 
				
			||||||
 | 
					        sql = """
 | 
				
			||||||
 | 
					            UPDATE oauth2_token
 | 
				
			||||||
 | 
					            SET access_token_revoked_at = %(revoked_at)s
 | 
				
			||||||
 | 
					            WHERE client_id = %(client_id)s;
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        db.engine.execute(
 | 
				
			||||||
 | 
					            sql, {'client_id': client_id, 'revoked_at': int(time.time())}
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,7 +5,7 @@ from flask import Blueprint, Response, request
 | 
				
			|||||||
from urllib3.util import parse_url
 | 
					from urllib3.util import parse_url
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from fittrackee import db
 | 
					from fittrackee import db
 | 
				
			||||||
from fittrackee.oauth2.models import OAuth2Client
 | 
					from fittrackee.oauth2.models import OAuth2Client, OAuth2Token
 | 
				
			||||||
from fittrackee.oauth2.server import require_auth
 | 
					from fittrackee.oauth2.server import require_auth
 | 
				
			||||||
from fittrackee.responses import (
 | 
					from fittrackee.responses import (
 | 
				
			||||||
    HttpResponse,
 | 
					    HttpResponse,
 | 
				
			||||||
@@ -152,6 +152,20 @@ def delete_client(
 | 
				
			|||||||
    return {'status': 'no content'}, 204
 | 
					    return {'status': 'no content'}, 204
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@oauth_blueprint.route('/oauth/apps/<int:client_id>/revoke', methods=['POST'])
 | 
				
			||||||
 | 
					@require_auth()
 | 
				
			||||||
 | 
					def revoke_client_tokens(
 | 
				
			||||||
 | 
					    auth_user: User, client_id: int
 | 
				
			||||||
 | 
					) -> Union[Dict, HttpResponse]:
 | 
				
			||||||
 | 
					    client = OAuth2Client.query.filter_by(id=client_id).first()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if not client:
 | 
				
			||||||
 | 
					        return NotFoundErrorResponse('OAuth client not found')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    OAuth2Token.revoke_client_tokens(client.client_id)
 | 
				
			||||||
 | 
					    return {'status': 'success'}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@oauth_blueprint.route('/oauth/authorize', methods=['POST'])
 | 
					@oauth_blueprint.route('/oauth/authorize', methods=['POST'])
 | 
				
			||||||
@require_auth()
 | 
					@require_auth()
 | 
				
			||||||
def authorize(auth_user: User) -> Union[HttpResponse, Dict]:
 | 
					def authorize(auth_user: User) -> Union[HttpResponse, Dict]:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
import json
 | 
					import json
 | 
				
			||||||
 | 
					import time
 | 
				
			||||||
from random import randint
 | 
					from random import randint
 | 
				
			||||||
from typing import Any, Dict, List, Optional, Tuple, Union
 | 
					from typing import Any, Dict, List, Optional, Tuple, Union
 | 
				
			||||||
from urllib.parse import parse_qs
 | 
					from urllib.parse import parse_qs
 | 
				
			||||||
@@ -10,7 +11,7 @@ from werkzeug.test import TestResponse
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from fittrackee import db
 | 
					from fittrackee import db
 | 
				
			||||||
from fittrackee.oauth2.client import create_oauth_client
 | 
					from fittrackee.oauth2.client import create_oauth_client
 | 
				
			||||||
from fittrackee.oauth2.models import OAuth2Client
 | 
					from fittrackee.oauth2.models import OAuth2Client, OAuth2Token
 | 
				
			||||||
from fittrackee.users.models import User
 | 
					from fittrackee.users.models import User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from .custom_asserts import (
 | 
					from .custom_asserts import (
 | 
				
			||||||
@@ -42,7 +43,45 @@ class RandomMixin:
 | 
				
			|||||||
        return randint(min_val, max_val)
 | 
					        return randint(min_val, max_val)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ApiTestCaseMixin(RandomMixin):
 | 
					class OAuth2Mixin(RandomMixin):
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def create_oauth_client(
 | 
				
			||||||
 | 
					        user: User,
 | 
				
			||||||
 | 
					        metadata: Optional[Dict] = None,
 | 
				
			||||||
 | 
					        scope: Optional[str] = None,
 | 
				
			||||||
 | 
					    ) -> OAuth2Client:
 | 
				
			||||||
 | 
					        client_metadata = (
 | 
				
			||||||
 | 
					            TEST_OAUTH_CLIENT_METADATA if metadata is None else metadata
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if scope is not None:
 | 
				
			||||||
 | 
					            client_metadata['scope'] = scope
 | 
				
			||||||
 | 
					        oauth_client = create_oauth_client(client_metadata, user)
 | 
				
			||||||
 | 
					        db.session.add(oauth_client)
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 | 
					        return oauth_client
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def create_oauth2_token(
 | 
				
			||||||
 | 
					        self,
 | 
				
			||||||
 | 
					        oauth_client: OAuth2Client,
 | 
				
			||||||
 | 
					        issued_at: Optional[int] = None,
 | 
				
			||||||
 | 
					        access_token_revoked_at: Optional[int] = 0,
 | 
				
			||||||
 | 
					        expires_in: Optional[int] = 1000,
 | 
				
			||||||
 | 
					    ) -> OAuth2Token:
 | 
				
			||||||
 | 
					        issued_at = issued_at if issued_at else int(time.time())
 | 
				
			||||||
 | 
					        token = OAuth2Token(
 | 
				
			||||||
 | 
					            client_id=oauth_client.client_id,
 | 
				
			||||||
 | 
					            access_token=self.random_string(),
 | 
				
			||||||
 | 
					            refresh_token=self.random_string(),
 | 
				
			||||||
 | 
					            issued_at=issued_at,
 | 
				
			||||||
 | 
					            access_token_revoked_at=access_token_revoked_at,
 | 
				
			||||||
 | 
					            expires_in=expires_in,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        db.session.add(token)
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 | 
					        return token
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ApiTestCaseMixin(OAuth2Mixin, RandomMixin):
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
    def get_test_client_and_auth_token(
 | 
					    def get_test_client_and_auth_token(
 | 
				
			||||||
        app: Flask, user_email: str
 | 
					        app: Flask, user_email: str
 | 
				
			||||||
@@ -61,22 +100,6 @@ class ApiTestCaseMixin(RandomMixin):
 | 
				
			|||||||
        auth_token = json.loads(resp_login.data.decode())['auth_token']
 | 
					        auth_token = json.loads(resp_login.data.decode())['auth_token']
 | 
				
			||||||
        return client, auth_token
 | 
					        return client, auth_token
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					 | 
				
			||||||
    def create_oauth_client(
 | 
					 | 
				
			||||||
        user: User,
 | 
					 | 
				
			||||||
        metadata: Optional[Dict] = None,
 | 
					 | 
				
			||||||
        scope: Optional[str] = None,
 | 
					 | 
				
			||||||
    ) -> OAuth2Client:
 | 
					 | 
				
			||||||
        client_metadata = (
 | 
					 | 
				
			||||||
            TEST_OAUTH_CLIENT_METADATA if metadata is None else metadata
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        if scope is not None:
 | 
					 | 
				
			||||||
            client_metadata['scope'] = scope
 | 
					 | 
				
			||||||
        oauth_client = create_oauth_client(client_metadata, user)
 | 
					 | 
				
			||||||
        db.session.add(oauth_client)
 | 
					 | 
				
			||||||
        db.session.commit()
 | 
					 | 
				
			||||||
        return oauth_client
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
    def authorize_client(
 | 
					    def authorize_client(
 | 
				
			||||||
        client: FlaskClient,
 | 
					        client: FlaskClient,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -992,3 +992,75 @@ class TestOAuthDeleteClient(ApiTestCaseMixin):
 | 
				
			|||||||
        self.assert_404_with_message(response, 'OAuth client not found')
 | 
					        self.assert_404_with_message(response, 'OAuth client not found')
 | 
				
			||||||
        client = OAuth2Client.query.filter_by(id=client_id).first()
 | 
					        client = OAuth2Client.query.filter_by(id=client_id).first()
 | 
				
			||||||
        assert client is not None
 | 
					        assert client is not None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestOAuthRevokeClientToken(ApiTestCaseMixin):
 | 
				
			||||||
 | 
					    route = '/api/oauth/apps/{client_id}/revoke'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_it_returns_error_when_not_authenticated(
 | 
				
			||||||
 | 
					        self, app: Flask, user_1: User
 | 
				
			||||||
 | 
					    ) -> None:
 | 
				
			||||||
 | 
					        client = app.test_client()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(
 | 
				
			||||||
 | 
					            self.route.format(client_id=self.random_int()),
 | 
				
			||||||
 | 
					            content_type='application/json',
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assert_401(response)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_it_returns_error_when_client_not_found(
 | 
				
			||||||
 | 
					        self, app: Flask, user_1: User
 | 
				
			||||||
 | 
					    ) -> None:
 | 
				
			||||||
 | 
					        client, auth_token = self.get_test_client_and_auth_token(
 | 
				
			||||||
 | 
					            app, user_1.email
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(
 | 
				
			||||||
 | 
					            self.route.format(client_id=self.random_int()),
 | 
				
			||||||
 | 
					            content_type='application/json',
 | 
				
			||||||
 | 
					            headers=dict(Authorization=f'Bearer {auth_token}'),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assert_404_with_message(response, 'OAuth client not found')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_it_revokes_all_client_tokens(
 | 
				
			||||||
 | 
					        self, app: Flask, user_1: User
 | 
				
			||||||
 | 
					    ) -> None:
 | 
				
			||||||
 | 
					        client, auth_token = self.get_test_client_and_auth_token(
 | 
				
			||||||
 | 
					            app, user_1.email
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        oauth_client = self.create_oauth_client(user_1)
 | 
				
			||||||
 | 
					        tokens = [self.create_oauth2_token(oauth_client) for _ in range(3)]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(
 | 
				
			||||||
 | 
					            self.route.format(client_id=oauth_client.id),
 | 
				
			||||||
 | 
					            content_type='application/json',
 | 
				
			||||||
 | 
					            headers=dict(Authorization=f'Bearer {auth_token}'),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert response.status_code == 200
 | 
				
			||||||
 | 
					        data = json.loads(response.data.decode())
 | 
				
			||||||
 | 
					        assert data['status'] == 'success'
 | 
				
			||||||
 | 
					        for token in tokens:
 | 
				
			||||||
 | 
					            assert token.is_revoked()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_it_does_not_revoke_another_client_token(
 | 
				
			||||||
 | 
					        self, app: Flask, user_1: User
 | 
				
			||||||
 | 
					    ) -> None:
 | 
				
			||||||
 | 
					        client, auth_token = self.get_test_client_and_auth_token(
 | 
				
			||||||
 | 
					            app, user_1.email
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        oauth_client = self.create_oauth_client(user_1)
 | 
				
			||||||
 | 
					        client_id = oauth_client.id
 | 
				
			||||||
 | 
					        another_client = self.create_oauth_client(user_1)
 | 
				
			||||||
 | 
					        another_client_token = self.create_oauth2_token(another_client)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(
 | 
				
			||||||
 | 
					            self.route.format(client_id=client_id),
 | 
				
			||||||
 | 
					            content_type='application/json',
 | 
				
			||||||
 | 
					            headers=dict(Authorization=f'Bearer {auth_token}'),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert response.status_code == 200
 | 
				
			||||||
 | 
					        assert not another_client_token.is_revoked()
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user