Skip to content

Commit

Permalink
feat: Add rate limiter (#1976)
Browse files Browse the repository at this point in the history
* Add rate limiter

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.

* Lint

* sigh conflicting linters

* further linting

* Address comments

* ParamSpec is only available > 3.8

* Use latest flask-limiter

* black

* old style flake8

* Fix timeline when inserting

---------

Co-authored-by: Daniel Vaz Gaspar <danielvazgaspar@gmail.com>
  • Loading branch information
bolkedebruin and dpgaspar committed Feb 23, 2023
1 parent 0132aad commit bef2421
Show file tree
Hide file tree
Showing 19 changed files with 322 additions and 29 deletions.
9 changes: 9 additions & 0 deletions docs/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,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 @@ -649,6 +651,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
30 changes: 18 additions & 12 deletions 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 Expand Up @@ -385,7 +402,6 @@ def get_init_inner_views(self):
"""
Sets initialized inner views
"""
pass

def get_method_permission(self, method_name: str) -> str:
"""
Expand Down Expand Up @@ -436,7 +452,6 @@ def form_get(self, form):
"""
Override this method to implement your form processing
"""
pass

def form_post(self, form):
"""
Expand All @@ -447,7 +462,6 @@ def form_post(self, form):
Return None or a flask response to render
a custom template or redirect the user
"""
pass

def _get_edit_widget(self, form=None, exclude_cols=None, widgets=None):
exclude_cols = exclude_cols or []
Expand Down Expand Up @@ -1380,7 +1394,6 @@ def prefill_form(self, form, pk):
if form.email.data:
form.email_confirmation.data = form.email.data
"""
pass

def process_form(self, form, is_created):
"""
Expand All @@ -1396,7 +1409,6 @@ def process_form(self, form, is_created):
if not form.owner:
form.owner.data = 'n/a'
"""
pass

def pre_update(self, item):
"""
Expand All @@ -1407,27 +1419,23 @@ def pre_update(self, item):
implement more complex logic around updates. For instance
allowing only the original creator of the object to update it.
"""
pass

def post_update(self, item):
"""
Override this, will be called after update
"""
pass

def pre_add(self, item):
"""
Override this, will be called before add.
If an exception is raised by this method,
the message is shown to the user and the add operation is aborted.
"""
pass

def post_add(self, item):
"""
Override this, will be called after update
"""
pass

def pre_delete(self, item):
"""
Expand All @@ -1438,10 +1446,8 @@ def pre_delete(self, item):
implement more complex logic around deletes. For instance
allowing only the original creator of the object to delete it.
"""
pass

def post_delete(self, item):
"""
Override this, will be called after delete
"""
pass
73 changes: 72 additions & 1 deletion 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 Callable, List, Optional, TypeVar, Union

from flask import (
current_app,
Expand All @@ -17,12 +18,17 @@
LOGMSG_ERR_SEC_ACCESS_DENIED,
PERMISSION_PREFIX,
)
from flask_appbuilder.utils.limit import Limit
from flask_jwt_extended import verify_jwt_in_request
from flask_limiter.wrappers import RequestLimit
from flask_login import current_user

from typing_extensions import ParamSpec

log = logging.getLogger(__name__)

R = TypeVar("R")
P = ParamSpec("P")


def response_unauthorized_mvc(status_code: int) -> Response:
response = make_response(
Expand Down Expand Up @@ -228,3 +234,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: Callable[P, R]) -> Callable[P, R]:
_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
49 changes: 48 additions & 1 deletion flask_appbuilder/security/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
import re
from typing import Any, Dict, List, Optional, Set, Tuple, Union

from flask import g, session, url_for
from flask import Flask, g, session, url_for
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: Flask) -> 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) -> bool:
return self.appbuilder.get_app.config["AUTH_RATE_LIMITED"]

@property
def auth_rate_limit(self) -> str:
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,13 @@ 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 +1577,24 @@ 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 not baseview.limits:
return

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
Loading

0 comments on commit bef2421

Please sign in to comment.