Skip to content

Commit

Permalink
Add rate limiter
Browse files Browse the repository at this point in the history
This allows regulation of the number of times a user can
request a particular API or view given a time-frame. It
protects against brute-forcing entry and denial of service.
  • Loading branch information
bolkedebruin committed Jan 9, 2023
1 parent 64fb8df commit aae57a6
Show file tree
Hide file tree
Showing 19 changed files with 314 additions and 18 deletions.
9 changes: 9 additions & 0 deletions docs/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,15 @@ Therefore, you can send tweets, post on the users Facebook, retrieve the user's
Take a look at the `example <https://github.com/dpgaspar/Flask-AppBuilder/tree/master/examples/oauth>`_
to get an idea of a simple use for this.

Authentication: Rate limiting
-----------------------------

To prevent brute-forcing of credentials, FlaskApplicationBuilder applies rate limits to AuthViews in 4.2.0, so that
only 2 POST requests can be made every 5 seconds. This can be disabled by setting ``AUTH_RATE_LIMITED`` to
``False`` or can be changed by adjusting ``AUTH_RATE_LIMIT`` to, for example, ``1 per 10 seconds``. Take a look
at the `documentation <https://flask-limiter.readthedocs.io/en/stable/>`_ of Flask-Limiter for more options and
examples.

Role based
----------

Expand Down
19 changes: 19 additions & 0 deletions flask_appbuilder/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
from ..hooks import get_before_request_hooks, wrap_route_handler_with_hooks
from ..models.filters import Filters
from ..security.decorators import permission_name, protect
from ..utils.limit import Limit

if TYPE_CHECKING:
from flask_appbuilder import AppBuilder
Expand Down Expand Up @@ -453,6 +454,18 @@ class GreetingApi(BaseApi):
Use this attribute to override the tag name
"""

limits: Optional[List[Limit]] = None
"""
List of limits for this api.
Use it like this if you want to restrict the rate of requests to a view:
class MyView(ModelView):
limits = [Limit("2 per 5 second")]
or use the decorator @limit.
"""

def __init__(self) -> None:
"""
Initialization of base permissions
Expand Down Expand Up @@ -490,7 +503,13 @@ def __init__(self) -> None:
if self.base_permissions is None:
self.base_permissions = set()
is_add_base_permissions = True

if self.limits is None:
self.limits = []

for attr_name in dir(self):
if hasattr(getattr(self, attr_name), "_limit"):
self.limits.append(getattr(getattr(self, attr_name), "_limit"))
# If include_route_methods is not None white list
if (
self.include_route_methods is not None
Expand Down
6 changes: 6 additions & 0 deletions flask_appbuilder/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ def add_view(
if self.app:
self.register_blueprint(baseview)
self._add_permission(baseview)
self.add_limits(baseview)
self.add_link(
name=name,
href=href,
Expand Down Expand Up @@ -564,6 +565,7 @@ def add_view_no_menu(
baseview, endpoint=endpoint, static_folder=static_folder
)
self._add_permission(baseview)
self.add_limits(baseview)
else:
log.warning(LOGMSG_WAR_FAB_VIEW_EXISTS.format(baseview.__class__.__name__))
return baseview
Expand Down Expand Up @@ -644,6 +646,10 @@ def get_url_for_locale(self, lang: str) -> str:
locale=lang,
)

def add_limits(self, baseview: "AbstractViewApi") -> None:
if hasattr(baseview, "limits"):
self.sm.add_limit_view(baseview)

def add_permissions(self, update_perms: bool = False) -> None:
from flask_appbuilder.baseviews import AbstractViewApi

Expand Down
19 changes: 18 additions & 1 deletion flask_appbuilder/baseviews.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,20 @@ class ContactModelView(ModelView):
default_view = "list"
""" the default view for this BaseView, to be used with url_for (method name) """
extra_args = None

""" dictionary for injecting extra arguments into template """

limits = None
"""
List of limits for this view.
Use it like this if you want to restrict the rate of requests to a view:
class MyView(ModelView):
limits = [Limit("2 per 5 second")]
or use the decorator @limit.
"""

_apis = None

def __init__(self):
Expand Down Expand Up @@ -212,6 +224,9 @@ def __init__(self):
self.base_permissions = set()
is_add_base_permissions = True

if self.limits is None:
self.limits = []

for attr_name in dir(self):
# If include_route_methods is not None white list
if (
Expand Down Expand Up @@ -239,6 +254,8 @@ def __init__(self):
_extra = getattr(getattr(self, attr_name), "_extra")
for key in _extra:
self._apis[key] = _extra[key]
if hasattr(getattr(self, attr_name), "_limit"):
self.limits.append(getattr(getattr(self, attr_name), "_limit"))

def create_blueprint(self, appbuilder, endpoint=None, static_folder=None):
"""
Expand Down
68 changes: 68 additions & 0 deletions flask_appbuilder/security/decorators.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import functools
import logging
from typing import Optional, List, Union, Callable

from flask import (
current_app,
Expand All @@ -18,8 +19,10 @@
PERMISSION_PREFIX,
)
from flask_jwt_extended import verify_jwt_in_request
from flask_limiter.wrappers import RequestLimit
from flask_login import current_user

from flask_appbuilder.utils.limit import Limit

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -228,3 +231,68 @@ def wraps(f):
return f

return wraps


def limit(
limit_value: Union[str, Callable[[], str]],
key_func: Optional[Callable[[], str]] = None,
per_method: bool = False,
methods: Optional[List[str]] = None,
error_message: Optional[str] = None,
exempt_when: Optional[Callable[[], bool]] = None,
override_defaults: bool = True,
deduct_when: Optional[Callable[[Response], bool]] = None,
on_breach: Optional[Callable[[RequestLimit], Optional[Response]]] = None,
cost: Union[int, Callable[[], int]] = 1,
):
"""
Decorator to be used for rate limiting individual routes or blueprints.
:param limit_value: rate limit string or a callable that returns a
string. :ref:`ratelimit-string` for more details.
:param key_func: function/lambda to extract the unique
identifier for the rate limit. defaults to remote address of the
request.
:param per_method: whether the limit is sub categorized into the
http method of the request.
:param methods: if specified, only the methods in this list will
be rate limited (default: ``None``).
:param error_message: string (or callable that returns one) to override
the error message used in the response.
:param exempt_when: function/lambda used to decide if the rate
limit should skipped.
:param override_defaults: whether the decorated limit overrides
the default limits (Default: ``True``).
.. note:: When used with a :class:`~BaseView` the meaning
of the parameter extends to any parents the blueprint instance is
registered under. For more details see :ref:`recipes:nested blueprints`
:param deduct_when: a function that receives the current
:class:`flask.Response` object and returns True/False to decide if a
deduction should be done from the rate limit
:param on_breach: a function that will be called when this limit
is breached. If the function returns an instance of :class:`flask.Response`
that will be the response embedded into the :exc:`RateLimitExceeded` exception
raised.
:param cost: The cost of a hit or a function that
takes no parameters and returns the cost as an integer (Default: ``1``).
"""

def wraps(f):
_limit = Limit(
limit_value=limit_value,
key_func=key_func,
per_method=per_method,
methods=methods,
error_message=error_message,
exempt_when=exempt_when,
override_defaults=override_defaults,
deduct_when=deduct_when,
on_breach=on_breach,
cost=cost,
)
f._limit = _limit
return f

return wraps
44 changes: 44 additions & 0 deletions flask_appbuilder/security/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from flask_babel import lazy_gettext as _
from flask_jwt_extended import current_user as current_user_jwt
from flask_jwt_extended import JWTManager
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_login import current_user, LoginManager
from werkzeug.security import check_password_hash, generate_password_hash

Expand Down Expand Up @@ -255,6 +257,10 @@ def __init__(self, appbuilder):
app.config.setdefault("AUTH_LDAP_LASTNAME_FIELD", "sn")
app.config.setdefault("AUTH_LDAP_EMAIL_FIELD", "mail")

# Rate limiting
app.config.setdefault("AUTH_RATE_LIMITED", True)
app.config.setdefault("AUTH_RATE_LIMIT", "2 per 5 second")

if self.auth_type == AUTH_OID:
from flask_openid import OpenID

Expand Down Expand Up @@ -285,6 +291,14 @@ def __init__(self, appbuilder):
# Setup Flask-Jwt-Extended
self.jwt_manager = self.create_jwt_manager(app)

# Setup Flask-Limiter
self.limiter = self.create_limiter(app)

def create_limiter(self, app) -> Limiter:
limiter = Limiter(key_func=get_remote_address)
limiter.init_app(app)
return limiter

def create_login_manager(self, app) -> LoginManager:
"""
Override to implement your custom login manager instance
Expand Down Expand Up @@ -489,6 +503,14 @@ def openid_providers(self):
def oauth_providers(self):
return self.appbuilder.get_app.config["OAUTH_PROVIDERS"]

@property
def is_auth_limited(self):
return self.appbuilder.get_app.config["AUTH_RATE_LIMITED"]

@property
def auth_rate_limit(self):
return self.appbuilder.get_app.config["AUTH_RATE_LIMIT"]

@property
def current_user(self):
if current_user.is_authenticated:
Expand Down Expand Up @@ -735,6 +757,12 @@ def register_views(self):

self.appbuilder.add_view_no_menu(self.auth_view)

# this needs to be done after the view is added, otherwise the blueprint is not initialized
if self.is_auth_limited:
self.limiter.limit(self.auth_rate_limit, methods=["POST"])(
self.auth_view.blueprint
)

self.user_view = self.appbuilder.add_view(
self.user_view,
"List Users",
Expand Down Expand Up @@ -1548,6 +1576,22 @@ def get_user_menu_access(self, menu_names: List[str] = None) -> Set[str]:
None, "menu_access", view_menus_name=menu_names
)

def add_limit_view(self, baseview):
if baseview.limits:
for limit in baseview.limits:
self.limiter.limit(
limit_value=limit.limit_value,
key_func=limit.key_func,
per_method=limit.per_method,
methods=limit.methods,
error_message=limit.error_message,
exempt_when=limit.exempt_when,
override_defaults=limit.override_defaults,
deduct_when=limit.deduct_when,
on_breach=limit.on_breach,
cost=limit.cost,
)(baseview.blueprint)

def add_permissions_view(self, base_permissions, view_menu):
"""
Adds a permission on a view menu to the backend
Expand Down
2 changes: 1 addition & 1 deletion flask_appbuilder/security/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from flask_appbuilder.baseviews import BaseView
from flask_appbuilder.charts.views import DirectByChartView
from flask_appbuilder.fieldwidgets import BS3PasswordFieldWidget
from flask_appbuilder.security.decorators import has_access
from flask_appbuilder.security.decorators import has_access, limit
from flask_appbuilder.security.forms import (
DynamicForm,
LoginForm_db,
Expand Down
12 changes: 7 additions & 5 deletions flask_appbuilder/tests/A_fixture/test_0_fixture.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from flask_appbuilder import SQLA
from freezegun import freeze_time
from datetime import datetime

from hiro import Timeline

from flask_appbuilder import SQLA
from ..base import FABTestCase
from ..const import (
MODEL1_DATA_SIZE,
Expand All @@ -23,15 +25,15 @@ def setUp(self):
self.appbuilder = AppBuilder(self.app, self.db.session)

def test_data(self):
with freeze_time("2020-01-01"):
with Timeline(start=datetime(2020, 1, 1)).freeze():
insert_data(self.db.session, MODEL1_DATA_SIZE)

def test_create_admin(self):
with freeze_time("2020-01-01"):
with Timeline(start=datetime(2020, 1, 1)).freeze():
self.create_admin_user(self.appbuilder, USERNAME_ADMIN, PASSWORD_ADMIN)

def test_create_ro_user(self):
with freeze_time("2020-01-01"):
with Timeline(start=datetime(2020, 1, 1)).freeze():
self.create_user(
self.appbuilder,
USERNAME_READONLY,
Expand Down
2 changes: 2 additions & 0 deletions flask_appbuilder/tests/config_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@
[".*", "can_show"],
]
}

RATELIMIT_ENABLED = False
2 changes: 2 additions & 0 deletions flask_appbuilder/tests/config_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@

# The default user self registration role for all users
AUTH_USER_REGISTRATION_ROLE = "Admin"

RATELIMIT_ENABLED = False
1 change: 1 addition & 0 deletions flask_appbuilder/tests/config_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
"FAB_ROLE1": [["Model1View", "can_list"], ["Model2View", "can_list"]],
"FAB_ROLE2": [["Model3View", "can_list"], ["Model4View", "can_list"]],
}
RATELIMIT_ENABLED = False
1 change: 1 addition & 0 deletions flask_appbuilder/tests/config_security_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@
[".*", "can_show"],
]
}
RATELIMIT_ENABLED = False
1 change: 1 addition & 0 deletions flask_appbuilder/tests/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
MAX_PAGE_SIZE = 25
USERNAME_READONLY = "readonly"
PASSWORD_READONLY = "readonly"
INVALID_LOGIN_STRING = "Invalid login"
12 changes: 8 additions & 4 deletions flask_appbuilder/tests/security/test_mvc_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
from flask_appbuilder.models.sqla.filters import FilterEqual
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_appbuilder.security.sqla.models import User

from ..base import BaseMVCTestCase
from ..const import PASSWORD_ADMIN, PASSWORD_READONLY, USERNAME_ADMIN, USERNAME_READONLY
from ..const import (
PASSWORD_ADMIN,
PASSWORD_READONLY,
USERNAME_ADMIN,
USERNAME_READONLY,
INVALID_LOGIN_STRING,
)
from ..sqla.models import Model1, Model2

INVALID_LOGIN_STRING = "Invalid login"
PASSWORD_COMPLEXITY_ERROR = (
"Must have at least two capital letters, "
"one special character, two digits, three lower case letters and "
Expand Down Expand Up @@ -203,7 +207,7 @@ def test_db_login_invalid_control_characters_next_url(self):
self.client,
USERNAME_ADMIN,
PASSWORD_ADMIN,
next_url=u"\u0001" + "sample.com",
next_url="\u0001" + "sample.com",
follow_redirects=False,
)
assert response.location == "http://localhost/"
Expand Down
Loading

0 comments on commit aae57a6

Please sign in to comment.