diff --git a/docsrc/source/cli.rst b/docsrc/source/cli.rst index a9e17c40..7249522e 100644 --- a/docsrc/source/cli.rst +++ b/docsrc/source/cli.rst @@ -65,6 +65,23 @@ Remove tokens expired for more than provided number of days Users ~~~~~ +``ftcli users clean_tokens`` +"""""""""""""""""""""""""""" +.. versionadded:: 0.7.0 + +Remove blacklisted tokens expired for more than provided number of days. + +.. cssclass:: table-bordered +.. list-table:: + :widths: 25 50 + :header-rows: 1 + + * - Options + - Description + * - ``--days`` + - Number of days. + + ``ftcli users update`` """""""""""""""""""""" .. versionadded:: 0.6.5 diff --git a/fittrackee/migrations/versions/25_84d840ce853b_add_oauth.py b/fittrackee/migrations/versions/25_84d840ce853b_add_oauth_and_blacklisted_tokens.py similarity index 98% rename from fittrackee/migrations/versions/25_84d840ce853b_add_oauth.py rename to fittrackee/migrations/versions/25_84d840ce853b_add_oauth_and_blacklisted_tokens.py index a8be47c6..7c92efd0 100644 --- a/fittrackee/migrations/versions/25_84d840ce853b_add_oauth.py +++ b/fittrackee/migrations/versions/25_84d840ce853b_add_oauth_and_blacklisted_tokens.py @@ -71,6 +71,7 @@ def upgrade(): op.create_table('blacklisted_tokens', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('token', sa.String(length=500), nullable=False), + sa.Column('expired_at', sa.Integer(), nullable=False), sa.Column('blacklisted_on', sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('token') diff --git a/fittrackee/oauth2/clean.py b/fittrackee/oauth2/clean.py index 401d9795..379863d1 100644 --- a/fittrackee/oauth2/clean.py +++ b/fittrackee/oauth2/clean.py @@ -1,13 +1,9 @@ -import time - -from fittrackee import db +from fittrackee.utils import clean def clean_tokens(days: int) -> int: - limit = int(time.time()) - (days * 86400) sql = """ DELETE FROM oauth2_token WHERE oauth2_token.issued_at + oauth2_token.expires_in < %(limit)s; """ - result = db.engine.execute(sql, {'limit': limit}) - return result.rowcount + return clean(sql, days) diff --git a/fittrackee/oauth2/commands.py b/fittrackee/oauth2/commands.py index 133151a4..ef147fdd 100644 --- a/fittrackee/oauth2/commands.py +++ b/fittrackee/oauth2/commands.py @@ -7,7 +7,7 @@ from fittrackee.cli.app import app from .clean import clean_tokens handler = logging.StreamHandler() -logger = logging.getLogger('fittrackee_clean_tokens') +logger = logging.getLogger('fittrackee_clean_oauth2_tokens') logger.setLevel(logging.INFO) logger.addHandler(handler) diff --git a/fittrackee/tests/mixins.py b/fittrackee/tests/mixins.py index 33f9f330..344ce9bc 100644 --- a/fittrackee/tests/mixins.py +++ b/fittrackee/tests/mixins.py @@ -1,6 +1,5 @@ import json import time -from random import randint from typing import Dict, List, Optional, Tuple, Union from urllib.parse import parse_qs @@ -18,7 +17,12 @@ from .custom_asserts import ( assert_errored_response, assert_oauth_errored_response, ) -from .utils import TEST_OAUTH_CLIENT_METADATA, random_email, random_string +from .utils import ( + TEST_OAUTH_CLIENT_METADATA, + random_email, + random_int, + random_string, +) class RandomMixin: @@ -40,7 +44,7 @@ class RandomMixin: @staticmethod def random_int(min_val: int = 0, max_val: int = 999999) -> int: - return randint(min_val, max_val) + return random_int(min_val, max_val) class OAuth2Mixin(RandomMixin): diff --git a/fittrackee/tests/users/test_users_utils.py b/fittrackee/tests/users/test_users_utils.py index 5b871017..cc1a27e1 100644 --- a/fittrackee/tests/users/test_users_utils.py +++ b/fittrackee/tests/users/test_users_utils.py @@ -1,6 +1,7 @@ +import time from calendar import timegm from datetime import datetime, timedelta -from typing import Dict +from typing import Dict, Optional from unittest.mock import Mock, patch import jwt @@ -9,13 +10,12 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from flask import Flask -from fittrackee import bcrypt -from fittrackee.tests.utils import random_string +from fittrackee import bcrypt, db from fittrackee.users.exceptions import ( InvalidEmailException, UserNotFoundException, ) -from fittrackee.users.models import User +from fittrackee.users.models import BlacklistedToken, User from fittrackee.users.utils.admin import UserManagerService from fittrackee.users.utils.controls import ( check_password, @@ -23,9 +23,13 @@ from fittrackee.users.utils.controls import ( is_valid_email, register_controls, ) -from fittrackee.users.utils.token import decode_user_token, get_user_token +from fittrackee.users.utils.token import ( + clean_blacklisted_tokens, + decode_user_token, + get_user_token, +) -from ..utils import random_email +from ..utils import random_email, random_int, random_string class TestUserManagerService: @@ -511,3 +515,67 @@ class TestDecodeUserToken: user_id = decode_user_token(token) assert user_id == expected_user_id + + +class TestBlacklistedTokensCleanup: + @staticmethod + def blacklisted_token(expiration_days: Optional[int] = None) -> str: + token = get_user_token(user_id=random_int()) + blacklisted_token = BlacklistedToken(token=token) + if expiration_days is not None: + blacklisted_token.expired_at = int(time.time()) - ( + expiration_days * 86400 + ) + db.session.add(blacklisted_token) + db.session.commit() + return token + + def test_it_returns_0_as_count_when_no_blacklisted_token_deleted( + self, app: Flask, user_1: User + ) -> None: + count = clean_blacklisted_tokens(days=30) + + assert count == 0 + + def test_it_does_not_delete_blacklisted_token_when_not_expired( + self, app: Flask, user_1: User + ) -> None: + token = self.blacklisted_token() + + clean_blacklisted_tokens(days=10) + + existing_token = BlacklistedToken.query.filter_by(token=token).first() + assert existing_token is not None + + def test_it_deletes_blacklisted_token_when_expired_more_then_provided_days( + self, app: Flask, user_1: User + ) -> None: + token = self.blacklisted_token(expiration_days=40) + + clean_blacklisted_tokens(days=30) + + existing_token = BlacklistedToken.query.filter_by(token=token).first() + assert existing_token is None + + def test_it_does_not_delete_blacklisted_token_when_expired_below_provided_days( # noqa + self, app: Flask, user_1: User + ) -> None: + token = self.blacklisted_token(expiration_days=30) + + clean_blacklisted_tokens(days=40) + + existing_token = BlacklistedToken.query.filter_by(token=token).first() + assert existing_token is not None + + def test_it_returns_deleted_rows_count( + self, app: Flask, user_1: User + ) -> None: + self.blacklisted_token() + for _ in range(3): + self.blacklisted_token(expiration_days=30) + + count = clean_blacklisted_tokens( + days=app.config['TOKEN_EXPIRATION_DAYS'] + ) + + assert count == 3 diff --git a/fittrackee/tests/utils.py b/fittrackee/tests/utils.py index aa8ebaf4..eb4df16c 100644 --- a/fittrackee/tests/utils.py +++ b/fittrackee/tests/utils.py @@ -35,6 +35,10 @@ def random_email() -> str: return random_string(suffix='@example.com') +def random_int(min_val: int = 0, max_val: int = 999999) -> int: + return random.randint(min_val, max_val) + + def random_short_id() -> str: return encode_uuid(uuid4()) diff --git a/fittrackee/users/commands.py b/fittrackee/users/commands.py index a5adece4..6a4be214 100644 --- a/fittrackee/users/commands.py +++ b/fittrackee/users/commands.py @@ -1,3 +1,4 @@ +import logging from typing import Optional import click @@ -5,6 +6,12 @@ import click from fittrackee.cli.app import app from fittrackee.users.exceptions import UserNotFoundException from fittrackee.users.utils.admin import UserManagerService +from fittrackee.users.utils.token import clean_blacklisted_tokens + +handler = logging.StreamHandler() +logger = logging.getLogger('fittrackee_clean_blacklisted_tokens') +logger.setLevel(logging.INFO) +logger.addHandler(handler) @click.group(name='users') @@ -60,3 +67,16 @@ def manage_user( ) except Exception as e: click.echo(f'An error occurred: {e}', err=True) + + +@users_cli.command('clean_tokens') +@click.option('--days', type=int, required=True, help='Number of days.') +def clean( + days: int, +) -> None: + """ + Clean blacklisted tokens expired for more than provided number of days. + """ + with app.app_context(): + deleted_rows = clean_blacklisted_tokens(days) + logger.info(f'Blacklisted tokens deleted: {deleted_rows}.') diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index d8fc48ee..08c9421d 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -244,11 +244,22 @@ class BlacklistedToken(BaseModel): id = db.Column(db.Integer, primary_key=True, autoincrement=True) token = db.Column(db.String(500), unique=True, nullable=False) + expired_at = db.Column(db.Integer, nullable=False) blacklisted_on = db.Column(db.DateTime, nullable=False) - def __init__(self, token: str) -> None: + def __init__( + self, token: str, blacklisted_on: Optional[datetime] = None + ) -> None: + payload = jwt.decode( + token, + current_app.config['SECRET_KEY'], + algorithms=['HS256'], + ) self.token = token - self.blacklisted_on = datetime.utcnow() + self.expired_at = payload['exp'] + self.blacklisted_on = ( + blacklisted_on if blacklisted_on else datetime.utcnow() + ) @classmethod def check(cls, auth_token: str) -> bool: diff --git a/fittrackee/users/utils/token.py b/fittrackee/users/utils/token.py index c0e151ae..0349ea36 100644 --- a/fittrackee/users/utils/token.py +++ b/fittrackee/users/utils/token.py @@ -4,6 +4,8 @@ from typing import Optional import jwt from flask import current_app +from fittrackee.utils import clean + def get_user_token( user_id: int, password_reset: Optional[bool] = False @@ -45,3 +47,14 @@ def decode_user_token(auth_token: str) -> int: algorithms=['HS256'], ) return payload['sub'] + + +def clean_blacklisted_tokens(days: int) -> int: + """ + Delete blacklisted tokens expired for more than provided number of days + """ + sql = """ + DELETE FROM blacklisted_tokens + WHERE blacklisted_tokens.expired_at < %(limit)s; + """ + return clean(sql, days) diff --git a/fittrackee/utils.py b/fittrackee/utils.py index a28323df..b777e992 100644 --- a/fittrackee/utils.py +++ b/fittrackee/utils.py @@ -1,8 +1,11 @@ +import time from datetime import timedelta from typing import Optional import humanize +from fittrackee import db + def get_readable_duration(duration: int, locale: Optional[str] = None) -> str: """ @@ -19,3 +22,9 @@ def get_readable_duration(duration: int, locale: Optional[str] = None) -> str: if locale != 'en': humanize.i18n.deactivate() return readable_duration + + +def clean(sql: str, days: int) -> int: + limit = int(time.time()) - (days * 86400) + result = db.engine.execute(sql, {'limit': limit}) + return result.rowcount