CLI - add command to clean blacklisted tokens

This commit is contained in:
Sam 2022-09-15 13:14:55 +02:00
parent e39fc3d211
commit 9b377c08e4
11 changed files with 161 additions and 18 deletions

View File

@ -65,6 +65,23 @@ Remove tokens expired for more than provided number of days
Users 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`` ``ftcli users update``
"""""""""""""""""""""" """"""""""""""""""""""
.. versionadded:: 0.6.5 .. versionadded:: 0.6.5

View File

@ -71,6 +71,7 @@ def upgrade():
op.create_table('blacklisted_tokens', op.create_table('blacklisted_tokens',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('token', sa.String(length=500), 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.Column('blacklisted_on', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'), sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token') sa.UniqueConstraint('token')

View File

@ -1,13 +1,9 @@
import time from fittrackee.utils import clean
from fittrackee import db
def clean_tokens(days: int) -> int: def clean_tokens(days: int) -> int:
limit = int(time.time()) - (days * 86400)
sql = """ sql = """
DELETE FROM oauth2_token DELETE FROM oauth2_token
WHERE oauth2_token.issued_at + oauth2_token.expires_in < %(limit)s; WHERE oauth2_token.issued_at + oauth2_token.expires_in < %(limit)s;
""" """
result = db.engine.execute(sql, {'limit': limit}) return clean(sql, days)
return result.rowcount

View File

@ -7,7 +7,7 @@ from fittrackee.cli.app import app
from .clean import clean_tokens from .clean import clean_tokens
handler = logging.StreamHandler() handler = logging.StreamHandler()
logger = logging.getLogger('fittrackee_clean_tokens') logger = logging.getLogger('fittrackee_clean_oauth2_tokens')
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
logger.addHandler(handler) logger.addHandler(handler)

View File

@ -1,6 +1,5 @@
import json import json
import time import time
from random import randint
from typing import Dict, List, Optional, Tuple, Union from typing import Dict, List, Optional, Tuple, Union
from urllib.parse import parse_qs from urllib.parse import parse_qs
@ -18,7 +17,12 @@ from .custom_asserts import (
assert_errored_response, assert_errored_response,
assert_oauth_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: class RandomMixin:
@ -40,7 +44,7 @@ class RandomMixin:
@staticmethod @staticmethod
def random_int(min_val: int = 0, max_val: int = 999999) -> int: 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): class OAuth2Mixin(RandomMixin):

View File

@ -1,6 +1,7 @@
import time
from calendar import timegm from calendar import timegm
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Dict from typing import Dict, Optional
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import jwt import jwt
@ -9,13 +10,12 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from flask import Flask from flask import Flask
from fittrackee import bcrypt from fittrackee import bcrypt, db
from fittrackee.tests.utils import random_string
from fittrackee.users.exceptions import ( from fittrackee.users.exceptions import (
InvalidEmailException, InvalidEmailException,
UserNotFoundException, 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.admin import UserManagerService
from fittrackee.users.utils.controls import ( from fittrackee.users.utils.controls import (
check_password, check_password,
@ -23,9 +23,13 @@ from fittrackee.users.utils.controls import (
is_valid_email, is_valid_email,
register_controls, 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: class TestUserManagerService:
@ -511,3 +515,67 @@ class TestDecodeUserToken:
user_id = decode_user_token(token) user_id = decode_user_token(token)
assert user_id == expected_user_id 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

View File

@ -35,6 +35,10 @@ def random_email() -> str:
return random_string(suffix='@example.com') 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: def random_short_id() -> str:
return encode_uuid(uuid4()) return encode_uuid(uuid4())

View File

@ -1,3 +1,4 @@
import logging
from typing import Optional from typing import Optional
import click import click
@ -5,6 +6,12 @@ import click
from fittrackee.cli.app import app from fittrackee.cli.app import app
from fittrackee.users.exceptions import UserNotFoundException from fittrackee.users.exceptions import UserNotFoundException
from fittrackee.users.utils.admin import UserManagerService 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') @click.group(name='users')
@ -60,3 +67,16 @@ def manage_user(
) )
except Exception as e: except Exception as e:
click.echo(f'An error occurred: {e}', err=True) 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}.')

View File

@ -244,11 +244,22 @@ class BlacklistedToken(BaseModel):
id = db.Column(db.Integer, primary_key=True, autoincrement=True) id = db.Column(db.Integer, primary_key=True, autoincrement=True)
token = db.Column(db.String(500), unique=True, nullable=False) 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) 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.token = token
self.blacklisted_on = datetime.utcnow() self.expired_at = payload['exp']
self.blacklisted_on = (
blacklisted_on if blacklisted_on else datetime.utcnow()
)
@classmethod @classmethod
def check(cls, auth_token: str) -> bool: def check(cls, auth_token: str) -> bool:

View File

@ -4,6 +4,8 @@ from typing import Optional
import jwt import jwt
from flask import current_app from flask import current_app
from fittrackee.utils import clean
def get_user_token( def get_user_token(
user_id: int, password_reset: Optional[bool] = False user_id: int, password_reset: Optional[bool] = False
@ -45,3 +47,14 @@ def decode_user_token(auth_token: str) -> int:
algorithms=['HS256'], algorithms=['HS256'],
) )
return payload['sub'] 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)

View File

@ -1,8 +1,11 @@
import time
from datetime import timedelta from datetime import timedelta
from typing import Optional from typing import Optional
import humanize import humanize
from fittrackee import db
def get_readable_duration(duration: int, locale: Optional[str] = None) -> str: 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': if locale != 'en':
humanize.i18n.deactivate() humanize.i18n.deactivate()
return readable_duration 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