diff --git a/fittrackee/application/app_config.py b/fittrackee/application/app_config.py index d623e2b4..008abf4d 100644 --- a/fittrackee/application/app_config.py +++ b/fittrackee/application/app_config.py @@ -4,12 +4,12 @@ from flask import Blueprint, current_app, request from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound from fittrackee import db +from fittrackee.oauth2.server import require_auth from fittrackee.responses import ( HttpResponse, InvalidPayloadErrorResponse, handle_error_and_return_response, ) -from fittrackee.users.decorators import authenticate_as_admin from fittrackee.users.models import User from fittrackee.users.utils.controls import is_valid_email @@ -67,7 +67,7 @@ def get_application_config() -> Union[Dict, HttpResponse]: @config_blueprint.route('/config', methods=['PATCH']) -@authenticate_as_admin +@require_auth(as_admin=True) def update_application_config(auth_user: User) -> Union[Dict, HttpResponse]: """ Update Application config diff --git a/fittrackee/oauth2/config.py b/fittrackee/oauth2/config.py index f47d0b5a..127f22bb 100644 --- a/fittrackee/oauth2/config.py +++ b/fittrackee/oauth2/config.py @@ -1,11 +1,14 @@ -from authlib.integrations.sqla_oauth2 import create_revocation_endpoint +from authlib.integrations.sqla_oauth2 import ( + create_bearer_token_validator, + create_revocation_endpoint, +) from authlib.oauth2.rfc7636 import CodeChallenge from flask import Flask from fittrackee import db from .grants import AuthorizationCodeGrant, OAuth2Token, RefreshTokenGrant -from .server import authorization_server +from .server import authorization_server, require_auth def config_oauth(app: Flask) -> None: @@ -21,3 +24,7 @@ def config_oauth(app: Flask) -> None: revocation_cls = create_revocation_endpoint(db.session, OAuth2Token) revocation_cls.CLIENT_AUTH_METHODS = ['client_secret_post'] authorization_server.register_endpoint(revocation_cls) + + # protect resource + bearer_cls = create_bearer_token_validator(db.session, OAuth2Token) + require_auth.register_token_validator(bearer_cls()) diff --git a/fittrackee/oauth2/resource_protector.py b/fittrackee/oauth2/resource_protector.py new file mode 100644 index 00000000..c08876c4 --- /dev/null +++ b/fittrackee/oauth2/resource_protector.py @@ -0,0 +1,80 @@ +from functools import wraps +from typing import Any, Callable, List, Union + +from authlib.integrations.flask_oauth2 import ResourceProtector +from authlib.oauth2 import OAuth2Error +from authlib.oauth2.rfc6749.errors import MissingAuthorizationError +from flask import current_app, request +from werkzeug.exceptions import RequestEntityTooLarge + +from fittrackee.responses import ( + ForbiddenErrorResponse, + PayloadTooLargeErrorResponse, + UnauthorizedErrorResponse, +) +from fittrackee.users.models import User + + +class CustomResourceProtector(ResourceProtector): + def __call__( + self, + scopes: Union[str, List] = None, + as_admin: bool = False, + ) -> Callable: + def wrapper(f: Callable) -> Callable: + @wraps(f) + def decorated(*args: Any, **kwargs: Any) -> Callable: + auth_user = None + auth_header = request.headers.get('Authorization') + if not auth_header: + return UnauthorizedErrorResponse( + 'provide a valid auth token' + ) + + # First-party application (Fittrackee front-end) + # in this case, scopes will be ignored + auth_token = auth_header.split(' ')[1] + resp = User.decode_auth_token(auth_token) + if isinstance(resp, int): + auth_user = User.query.filter_by(id=resp).first() + + # Third-party applications + if not auth_user: + current_token = None + try: + current_token = self.acquire_token(scopes) + except MissingAuthorizationError as error: + self.raise_error_response(error) + except OAuth2Error as error: + self.raise_error_response(error) + except RequestEntityTooLarge: + file_type = '' + if request.endpoint in [ + 'auth.edit_picture', + 'workouts.post_workout', + ]: + file_type = ( + 'picture' + if request.endpoint == 'auth.edit_picture' + else 'workout' + ) + return PayloadTooLargeErrorResponse( + file_type=file_type, + file_size=request.content_length, + max_size=current_app.config['MAX_CONTENT_LENGTH'], + ) + auth_user = ( + None if current_token is None else current_token.user + ) + + if not auth_user or not auth_user.is_active: + return UnauthorizedErrorResponse( + 'provide a valid auth token' + ) + if as_admin and not auth_user.admin: + return ForbiddenErrorResponse() + return f(auth_user, *args, **kwargs) + + return decorated + + return wrapper diff --git a/fittrackee/oauth2/routes.py b/fittrackee/oauth2/routes.py index a492ed28..56e5ecc2 100644 --- a/fittrackee/oauth2/routes.py +++ b/fittrackee/oauth2/routes.py @@ -3,8 +3,8 @@ from typing import Dict, Tuple, Union from flask import Blueprint, Response, request from fittrackee import db +from fittrackee.oauth2.server import require_auth from fittrackee.responses import HttpResponse, InvalidPayloadErrorResponse -from fittrackee.users.decorators import authenticate from fittrackee.users.models import User from .client import create_oauth_client @@ -21,7 +21,7 @@ EXPECTED_METADATA_KEYS = [ @oauth_blueprint.route('/oauth/apps', methods=['POST']) -@authenticate +@require_auth() def create_client(auth_user: User) -> Union[HttpResponse, Tuple[Dict, int]]: client_metadata = request.get_json() if not client_metadata: @@ -55,7 +55,7 @@ def create_client(auth_user: User) -> Union[HttpResponse, Tuple[Dict, int]]: @oauth_blueprint.route('/oauth/authorize', methods=['POST']) -@authenticate +@require_auth() def authorize(auth_user: User) -> Response: data = request.form if not data or 'client_id' not in data or 'response_type' not in data: diff --git a/fittrackee/oauth2/server.py b/fittrackee/oauth2/server.py index 2ec8ff6e..cca8e496 100644 --- a/fittrackee/oauth2/server.py +++ b/fittrackee/oauth2/server.py @@ -7,6 +7,7 @@ from authlib.integrations.sqla_oauth2 import ( from fittrackee import db from .models import OAuth2Client, OAuth2Token +from .resource_protector import CustomResourceProtector query_client = create_query_client_func(db.session, OAuth2Client) save_token = create_save_token_func(db.session, OAuth2Token) @@ -14,3 +15,4 @@ authorization_server = AuthorizationServer( query_client=query_client, save_token=save_token, ) +require_auth = CustomResourceProtector() diff --git a/fittrackee/tests/mixins.py b/fittrackee/tests/mixins.py index d46a49fd..fac0264b 100644 --- a/fittrackee/tests/mixins.py +++ b/fittrackee/tests/mixins.py @@ -147,6 +147,18 @@ class ApiTestCaseMixin(RandomMixin): error='invalid_request', ) + @staticmethod + def assert_invalid_token(response: TestResponse) -> Dict: + return assert_oauth_errored_response( + response, + 401, + error='invalid_token', + error_description=( + 'The access token provided is expired, revoked, malformed, ' + 'or invalid for other reasons.' + ), + ) + class CallArgsMixin: @staticmethod diff --git a/fittrackee/tests/users/test_auth_api.py b/fittrackee/tests/users/test_auth_api.py index 09b75f73..2b7b7510 100644 --- a/fittrackee/tests/users/test_auth_api.py +++ b/fittrackee/tests/users/test_auth_api.py @@ -477,7 +477,7 @@ class TestUserProfile(ApiTestCaseMixin): '/api/auth/profile', headers=dict(Authorization='Bearer invalid') ) - self.assert_401(response, 'invalid token, please log in again') + self.assert_invalid_token(response) def test_it_returns_user(self, app: Flask, user_1: User) -> None: client, auth_token = self.get_test_client_and_auth_token( diff --git a/fittrackee/users/auth.py b/fittrackee/users/auth.py index 0cfc34a4..f6a3335c 100644 --- a/fittrackee/users/auth.py +++ b/fittrackee/users/auth.py @@ -19,6 +19,7 @@ from fittrackee.emails.tasks import ( reset_password_email, ) from fittrackee.files import get_absolute_file_path +from fittrackee.oauth2.server import require_auth from fittrackee.responses import ( ForbiddenErrorResponse, HttpResponse, @@ -32,7 +33,6 @@ from fittrackee.responses import ( from fittrackee.utils import get_readable_duration from fittrackee.workouts.models import Sport -from .decorators import authenticate from .models import User, UserSportPreference from .utils.controls import check_password, is_valid_email, register_controls from .utils.token import decode_user_token @@ -252,7 +252,7 @@ def login_user() -> Union[Dict, HttpResponse]: @auth_blueprint.route('/auth/profile', methods=['GET']) -@authenticate +@require_auth() def get_authenticated_user_profile( auth_user: User, ) -> Union[Dict, HttpResponse]: @@ -354,7 +354,7 @@ def get_authenticated_user_profile( @auth_blueprint.route('/auth/profile/edit', methods=['POST']) -@authenticate +@require_auth() def edit_user(auth_user: User) -> Union[Dict, HttpResponse]: """ edit authenticated user profile @@ -502,7 +502,7 @@ def edit_user(auth_user: User) -> Union[Dict, HttpResponse]: @auth_blueprint.route('/auth/profile/edit/account', methods=['PATCH']) -@authenticate +@require_auth() def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]: """ update authenticated user email and password @@ -712,7 +712,7 @@ def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]: @auth_blueprint.route('/auth/profile/edit/preferences', methods=['POST']) -@authenticate +@require_auth() def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: """ edit authenticated user preferences @@ -853,7 +853,7 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: @auth_blueprint.route('/auth/profile/edit/sports', methods=['POST']) -@authenticate +@require_auth() def edit_user_sport_preferences( auth_user: User, ) -> Union[Dict, HttpResponse]: @@ -959,7 +959,7 @@ def edit_user_sport_preferences( @auth_blueprint.route( '/auth/profile/reset/sports/', methods=['DELETE'] ) -@authenticate +@require_auth() def reset_user_sport_preferences( auth_user: User, sport_id: int ) -> Union[Tuple[Dict, int], HttpResponse]: @@ -1014,7 +1014,7 @@ def reset_user_sport_preferences( @auth_blueprint.route('/auth/picture', methods=['POST']) -@authenticate +@require_auth() def edit_picture(auth_user: User) -> Union[Dict, HttpResponse]: """ update authenticated user picture @@ -1102,7 +1102,7 @@ def edit_picture(auth_user: User) -> Union[Dict, HttpResponse]: @auth_blueprint.route('/auth/picture', methods=['DELETE']) -@authenticate +@require_auth() def del_picture(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]: """ delete authenticated user picture diff --git a/fittrackee/users/decorators.py b/fittrackee/users/decorators.py deleted file mode 100644 index e3215b6c..00000000 --- a/fittrackee/users/decorators.py +++ /dev/null @@ -1,39 +0,0 @@ -from functools import wraps -from typing import Any, Callable, Union - -from flask import request - -from fittrackee.responses import HttpResponse - -from .utils.controls import verify_user - - -def verify_auth_user( - f: Callable, verify_admin: bool, *args: Any, **kwargs: Any -) -> Union[Callable, HttpResponse]: - response_object, user = verify_user(request, verify_admin=verify_admin) - if response_object: - return response_object - return f(user, *args, **kwargs) - - -def authenticate(f: Callable) -> Callable: - @wraps(f) - def decorated_function( - *args: Any, **kwargs: Any - ) -> Union[Callable, HttpResponse]: - verify_admin = False - return verify_auth_user(f, verify_admin, *args, **kwargs) - - return decorated_function - - -def authenticate_as_admin(f: Callable) -> Callable: - @wraps(f) - def decorated_function( - *args: Any, **kwargs: Any - ) -> Union[Callable, HttpResponse]: - verify_admin = True - return verify_auth_user(f, verify_admin, *args, **kwargs) - - return decorated_function diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index c6ce1349..47a4e1f1 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -12,6 +12,7 @@ from fittrackee.emails.tasks import ( reset_password_email, ) from fittrackee.files import get_absolute_file_path +from fittrackee.oauth2.server import require_auth from fittrackee.responses import ( ForbiddenErrorResponse, HttpResponse, @@ -23,7 +24,6 @@ from fittrackee.responses import ( from fittrackee.utils import get_readable_duration from fittrackee.workouts.models import Record, Workout, WorkoutSegment -from .decorators import authenticate, authenticate_as_admin from .exceptions import InvalidEmailException, UserNotFoundException from .models import User, UserSportPreference from .utils.admin import UserManagerService @@ -34,7 +34,7 @@ USER_PER_PAGE = 10 @users_blueprint.route('/users', methods=['GET']) -@authenticate_as_admin +@require_auth(as_admin=True) def get_users(auth_user: User) -> Dict: """ Get all users (regardless their account status), if authenticated user @@ -235,7 +235,7 @@ def get_users(auth_user: User) -> Dict: @users_blueprint.route('/users/', methods=['GET']) -@authenticate +@require_auth() def get_single_user( auth_user: User, user_name: str ) -> Union[Dict, HttpResponse]: @@ -394,7 +394,7 @@ def get_picture(user_name: str) -> Any: @users_blueprint.route('/users/', methods=['PATCH']) -@authenticate_as_admin +@require_auth(as_admin=True) def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: """ Update user account @@ -593,7 +593,7 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: @users_blueprint.route('/users/', methods=['DELETE']) -@authenticate +@require_auth() def delete_user( auth_user: User, user_name: str ) -> Union[Tuple[Dict, int], HttpResponse]: diff --git a/fittrackee/users/utils/controls.py b/fittrackee/users/utils/controls.py index 27c413c3..ffd0ad83 100644 --- a/fittrackee/users/utils/controls.py +++ b/fittrackee/users/utils/controls.py @@ -1,15 +1,4 @@ import re -from typing import Optional, Tuple - -from flask import Request - -from fittrackee.responses import ( - ForbiddenErrorResponse, - HttpResponse, - UnauthorizedErrorResponse, -) - -from ..models import User def is_valid_email(email: str) -> bool: @@ -58,30 +47,3 @@ def register_controls(username: str, email: str, password: str) -> str: ret += 'email: valid email must be provided\n' ret += check_password(password) return ret - - -def verify_user( - current_request: Request, verify_admin: bool -) -> Tuple[Optional[HttpResponse], Optional[User]]: - """ - Return authenticated user if - - the provided token is valid - - the user account is active - - the user has admin rights if 'verify_admin' is True - - If not, it returns Error Response - """ - default_message = 'provide a valid auth token' - auth_header = current_request.headers.get('Authorization') - if not auth_header: - return UnauthorizedErrorResponse(default_message), None - auth_token = auth_header.split(' ')[1] - resp = User.decode_auth_token(auth_token) - if isinstance(resp, str): - return UnauthorizedErrorResponse(resp), None - user = User.query.filter_by(id=resp).first() - if not user or not user.is_active: - return UnauthorizedErrorResponse(default_message), None - if verify_admin and not user.admin: - return ForbiddenErrorResponse(), None - return None, user diff --git a/fittrackee/workouts/records.py b/fittrackee/workouts/records.py index 5714fa8e..408dca0d 100644 --- a/fittrackee/workouts/records.py +++ b/fittrackee/workouts/records.py @@ -2,7 +2,7 @@ from typing import Dict from flask import Blueprint -from fittrackee.users.decorators import authenticate +from fittrackee.oauth2.server import require_auth from fittrackee.users.models import User from .models import Record @@ -11,7 +11,7 @@ records_blueprint = Blueprint('records', __name__) @records_blueprint.route('/records', methods=['GET']) -@authenticate +@require_auth() def get_records(auth_user: User) -> Dict: """ Get all records for authenticated user. diff --git a/fittrackee/workouts/sports.py b/fittrackee/workouts/sports.py index 9236068c..752d2504 100644 --- a/fittrackee/workouts/sports.py +++ b/fittrackee/workouts/sports.py @@ -4,13 +4,13 @@ from flask import Blueprint, request from sqlalchemy import exc from fittrackee import db +from fittrackee.oauth2.server import require_auth from fittrackee.responses import ( DataNotFoundErrorResponse, HttpResponse, InvalidPayloadErrorResponse, handle_error_and_return_response, ) -from fittrackee.users.decorators import authenticate, authenticate_as_admin from fittrackee.users.models import User, UserSportPreference from .models import Sport @@ -19,7 +19,7 @@ sports_blueprint = Blueprint('sports', __name__) @sports_blueprint.route('/sports', methods=['GET']) -@authenticate +@require_auth() def get_sports(auth_user: User) -> Dict: """ Get all sports @@ -195,7 +195,7 @@ def get_sports(auth_user: User) -> Dict: @sports_blueprint.route('/sports/', methods=['GET']) -@authenticate +@require_auth() def get_sport(auth_user: User, sport_id: int) -> Union[Dict, HttpResponse]: """ Get a sport @@ -304,7 +304,7 @@ def get_sport(auth_user: User, sport_id: int) -> Union[Dict, HttpResponse]: @sports_blueprint.route('/sports/', methods=['PATCH']) -@authenticate_as_admin +@require_auth(as_admin=True) def update_sport(auth_user: User, sport_id: int) -> Union[Dict, HttpResponse]: """ Update a sport diff --git a/fittrackee/workouts/stats.py b/fittrackee/workouts/stats.py index 9c3da148..c0f3776f 100644 --- a/fittrackee/workouts/stats.py +++ b/fittrackee/workouts/stats.py @@ -5,6 +5,7 @@ from flask import Blueprint, request from sqlalchemy import func from fittrackee import db +from fittrackee.oauth2.server import require_auth from fittrackee.responses import ( HttpResponse, InvalidPayloadErrorResponse, @@ -12,7 +13,6 @@ from fittrackee.responses import ( UserNotFoundErrorResponse, handle_error_and_return_response, ) -from fittrackee.users.decorators import authenticate, authenticate_as_admin from fittrackee.users.models import User from .models import Sport, Workout @@ -174,7 +174,7 @@ def get_workouts( @stats_blueprint.route('/stats//by_time', methods=['GET']) -@authenticate +@require_auth() def get_workouts_by_time( auth_user: User, user_name: str ) -> Union[Dict, HttpResponse]: @@ -281,7 +281,7 @@ def get_workouts_by_time( @stats_blueprint.route('/stats//by_sport', methods=['GET']) -@authenticate +@require_auth() def get_workouts_by_sport( auth_user: User, user_name: str ) -> Union[Dict, HttpResponse]: @@ -377,7 +377,7 @@ def get_workouts_by_sport( @stats_blueprint.route('/stats/all', methods=['GET']) -@authenticate_as_admin +@require_auth(as_admin=True) def get_application_stats(auth_user: User) -> Dict: """ Get all application statistics diff --git a/fittrackee/workouts/workouts.py b/fittrackee/workouts/workouts.py index d747c128..78f7ccf6 100644 --- a/fittrackee/workouts/workouts.py +++ b/fittrackee/workouts/workouts.py @@ -17,6 +17,7 @@ from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.utils import secure_filename from fittrackee import appLog, db +from fittrackee.oauth2.server import require_auth from fittrackee.responses import ( DataInvalidPayloadErrorResponse, DataNotFoundErrorResponse, @@ -28,7 +29,6 @@ from fittrackee.responses import ( get_error_response_if_file_is_invalid, handle_error_and_return_response, ) -from fittrackee.users.decorators import authenticate from fittrackee.users.models import User from .models import Workout @@ -56,7 +56,7 @@ MAX_WORKOUTS_PER_PAGE = 100 @workouts_blueprint.route('/workouts', methods=['GET']) -@authenticate +@require_auth() def get_workouts(auth_user: User) -> Union[Dict, HttpResponse]: """ Get workouts for the authenticated user. @@ -298,7 +298,7 @@ def get_workouts(auth_user: User) -> Union[Dict, HttpResponse]: @workouts_blueprint.route( '/workouts/', methods=['GET'] ) -@authenticate +@require_auth() def get_workout( auth_user: User, workout_short_id: str ) -> Union[Dict, HttpResponse]: @@ -462,7 +462,7 @@ def get_workout_data( @workouts_blueprint.route( '/workouts//gpx', methods=['GET'] ) -@authenticate +@require_auth() def get_workout_gpx( auth_user: User, workout_short_id: str ) -> Union[Dict, HttpResponse]: @@ -512,7 +512,7 @@ def get_workout_gpx( @workouts_blueprint.route( '/workouts//chart_data', methods=['GET'] ) -@authenticate +@require_auth() def get_workout_chart_data( auth_user: User, workout_short_id: str ) -> Union[Dict, HttpResponse]: @@ -582,7 +582,7 @@ def get_workout_chart_data( '/workouts//gpx/segment/', methods=['GET'], ) -@authenticate +@require_auth() def get_segment_gpx( auth_user: User, workout_short_id: str, segment_id: int ) -> Union[Dict, HttpResponse]: @@ -634,7 +634,7 @@ def get_segment_gpx( '', methods=['GET'], ) -@authenticate +@require_auth() def get_segment_chart_data( auth_user: User, workout_short_id: str, segment_id: int ) -> Union[Dict, HttpResponse]: @@ -705,7 +705,7 @@ def get_segment_chart_data( @workouts_blueprint.route( '/workouts//gpx/download', methods=['GET'] ) -@authenticate +@require_auth() def download_workout_gpx( auth_user: User, workout_short_id: str ) -> Union[HttpResponse, Response]: @@ -848,7 +848,7 @@ def get_map_tile(s: str, z: str, x: str, y: str) -> Tuple[Response, int]: @workouts_blueprint.route('/workouts', methods=['POST']) -@authenticate +@require_auth() def post_workout(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]: """ Post an workout with a gpx file @@ -1016,7 +1016,7 @@ def post_workout(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]: @workouts_blueprint.route('/workouts/no_gpx', methods=['POST']) -@authenticate +@require_auth() def post_workout_no_gpx( auth_user: User, ) -> Union[Tuple[Dict, int], HttpResponse]: @@ -1164,7 +1164,7 @@ def post_workout_no_gpx( @workouts_blueprint.route( '/workouts/', methods=['PATCH'] ) -@authenticate +@require_auth() def update_workout( auth_user: User, workout_short_id: str ) -> Union[Dict, HttpResponse]: @@ -1311,7 +1311,7 @@ def update_workout( @workouts_blueprint.route( '/workouts/', methods=['DELETE'] ) -@authenticate +@require_auth() def delete_workout( auth_user: User, workout_short_id: str ) -> Union[Tuple[Dict, int], HttpResponse]: