Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions invenio_accounts/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,48 @@
included, the one from the current request's context will be used.
"""

ACCOUNTS_FORGOT_PASSWORD_EMAIL_RATELIMIT = None
"""Flask-Limiter rate limit string for forgot-password requests per account.

Example: ``"3 per hour"``. Disabled when ``None``.
"""

ACCOUNTS_FORGOT_PASSWORD_EMAIL_RATELIMIT_KEY_PREFIX = "accounts.fp_email"
"""Prefix used to namespace forgot-password per-account limiter keys."""

ACCOUNTS_FORGOT_PASSWORD_EMAIL_RATELIMIT_MSG = _(
"Too many password-reset requests for this account. Please try again later."
)
"""Message shown when forgot-password per-account rate limit is exceeded."""

ACCOUNTS_LOGIN_RATELIMIT = None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to have None as default? Or maybe a good value instead as a default?
Same for all the other endpoints.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if Flask-Limiter is enabled by default in all instances... In invenio-app we always initialize the extension, but I'm not sure if there's another config flag that actually "enables" it.

I wanted to go around this assumption by not configuring any of the limits here. This is something we could do in invenio-app-rdm though where we have already configured e.g. Redis for the rate-limiting storage.

"""Flask-Limiter rate limit string for login requests per account.

Example: ``"5 per 15 minutes"``. Disabled when ``None``.
"""

ACCOUNTS_LOGIN_RATELIMIT_KEY_PREFIX = "accounts.login"
"""Prefix used to namespace login per-account limiter keys."""

ACCOUNTS_LOGIN_RATELIMIT_MSG = _(
"Too many login attempts for this account. Please try again later."
)
"""Message shown when login per-account rate limit is exceeded."""

ACCOUNTS_SEND_CONFIRMATION_RATELIMIT = None
"""Flask-Limiter rate limit string for send-confirmation requests per account.

Example: ``"3 per hour"``. Disabled when ``None``.
"""

ACCOUNTS_SEND_CONFIRMATION_RATELIMIT_KEY_PREFIX = "accounts.cf_email"
"""Prefix used to namespace send-confirmation per-account limiter keys."""

ACCOUNTS_SEND_CONFIRMATION_RATELIMIT_MSG = _(
"Too many confirmation-email requests for this account. Please try again later."
)
"""Message shown when send-confirmation per-account rate limit is exceeded."""

ACCOUNTS_REST_AUTH_VIEWS = {
"login": "invenio_accounts.views.rest:LoginView",
"logout": "invenio_accounts.views.rest:LogoutView",
Expand Down
9 changes: 9 additions & 0 deletions invenio_accounts/ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from invenio_accounts.forms import (
confirm_register_form_factory,
forgot_password_form_factory,
login_form_factory,
register_form_factory,
send_confirmation_form_factory,
Expand Down Expand Up @@ -180,6 +181,14 @@ def init_app(self, app, sessionstore=None, register_blueprint=True):
app.extensions["security"].login_form, app
)

if app.config.get("ACCOUNTS_FORGOT_PASSWORD_EMAIL_RATELIMIT"):
app.extensions["security"].forgot_password_form = (
forgot_password_form_factory(
app.extensions["security"].forgot_password_form,
app,
)
)

# send confirmation form
app.extensions["security"].send_confirmation_form = (
send_confirmation_form_factory(
Expand Down
37 changes: 36 additions & 1 deletion invenio_accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
from invenio_i18n import gettext as _
from wtforms import FormField, HiddenField

from .limiter import (
enforce_forgot_password_limit,
enforce_login_limit,
enforce_send_confirmation_limit,
)
from .proxies import current_datastore
from .utils import validate_domain

Expand Down Expand Up @@ -86,7 +91,16 @@ def login_form_factory(Form, app):
"""Return extended login form."""

class LoginForm(Form):
pass
def validate(self, extra_validators=None):
is_valid = super().validate(extra_validators=extra_validators)
if app.config.get("ACCOUNTS_LOGIN_RATELIMIT"):
allowed, message = enforce_login_limit(getattr(self, "user", None))
if not allowed:
self.form_errors = [*self.form_errors, str(message)]
self.email.errors = []
self.password.errors = []
return False
return is_valid

return LoginForm

Expand All @@ -107,7 +121,28 @@ def validate(self, extra_validators=None):
self.user = current_datastore.get_user(self.data["email"])
# Form is valid if user exists and they are not yet confirmed
if self.user is not None and self.user.confirmed_at is None:
if app.config.get("ACCOUNTS_SEND_CONFIRMATION_RATELIMIT"):
allowed, message = enforce_send_confirmation_limit(self.user)
if not allowed:
self.email.errors = [*self.email.errors, str(message)]
return False
return True
return False

return SendConfirmationEmailView


def forgot_password_form_factory(Form, app):
"""Return forgot-password form with per-account rate limiting."""

class ForgotPasswordForm(Form):
def validate(self, extra_validators=None):
if not super().validate(extra_validators=extra_validators):
return False
allowed, message = enforce_forgot_password_limit(self.user)
if not allowed:
self.email.errors = [*self.email.errors, str(message)]
return False
return True

return ForgotPasswordForm
63 changes: 63 additions & 0 deletions invenio_accounts/limiter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
#
# This file is part of Invenio.
# Copyright (C) 2026 CERN.
#
# Invenio is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.

"""Forgot-password rate limit helpers."""

from flask import current_app
from limits import parse_many


def _enforce_user_limit(user, limit_key, key_prefix_key, message_key):
limit_value = current_app.config.get(limit_key)
if not limit_value:
return True, None

limiter_ext = current_app.extensions["invenio-app"].limiter
key_prefix = current_app.config[key_prefix_key]

user_id = getattr(user, "id", None)
if user_id is None:
return True, None

key = str(user_id)

for item in parse_many(limit_value):
allowed = limiter_ext.limiter.hit(item, key_prefix, key)
if not allowed:
return False, current_app.config[message_key]
return True, None


def enforce_forgot_password_limit(user):
"""Return result for forgot-password per-account rate limit."""
return _enforce_user_limit(
user=user,
limit_key="ACCOUNTS_FORGOT_PASSWORD_EMAIL_RATELIMIT",
key_prefix_key="ACCOUNTS_FORGOT_PASSWORD_EMAIL_RATELIMIT_KEY_PREFIX",
message_key="ACCOUNTS_FORGOT_PASSWORD_EMAIL_RATELIMIT_MSG",
)


def enforce_login_limit(user):
"""Return result for login per-account rate limit."""
return _enforce_user_limit(
user=user,
limit_key="ACCOUNTS_LOGIN_RATELIMIT",
key_prefix_key="ACCOUNTS_LOGIN_RATELIMIT_KEY_PREFIX",
message_key="ACCOUNTS_LOGIN_RATELIMIT_MSG",
)


def enforce_send_confirmation_limit(user):
"""Return result for send-confirmation per-account rate limit."""
return _enforce_user_limit(
user=user,
limit_key="ACCOUNTS_SEND_CONFIRMATION_RATELIMIT",
key_prefix_key="ACCOUNTS_SEND_CONFIRMATION_RATELIMIT_KEY_PREFIX",
message_key="ACCOUNTS_SEND_CONFIRMATION_RATELIMIT_MSG",
)
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ <h1 class="ui small login header">{{ _('Log in to account') }}</h1>
<form action="{{ url_for_security('login') }}" method="POST"
name="login_user_form" class="ui big form">
{{ form.hidden_tag() }}
{{ form_errors(form) }}
{{ render_field(form.email, icon="user icon", autofocus=True, errormsg='email' in form.errors) }}
{{ render_field(form.password, icon="lock icon", errormsg='password' in form.errors) }}
<button type="submit" class="ui fluid large submit primary button">
Expand Down
11 changes: 10 additions & 1 deletion invenio_accounts/views/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from functools import wraps

from flask import Blueprint, after_this_request, current_app, jsonify
from flask import Blueprint, abort, after_this_request, current_app, jsonify
from flask.views import MethodView
from flask_login import login_required
from flask_security import current_user
Expand Down Expand Up @@ -41,6 +41,7 @@
from invenio_accounts.models import SessionActivity
from invenio_accounts.sessions import delete_session

from ..limiter import enforce_login_limit, enforce_send_confirmation_limit
from ..proxies import current_datastore, current_security
from ..utils import (
change_user_password,
Expand Down Expand Up @@ -275,6 +276,10 @@ def post(self, **kwargs):
_abort(get_message("LOCAL_LOGIN_DISABLED")[0])

user = self.get_user(**kwargs)
if current_app.config.get("ACCOUNTS_LOGIN_RATELIMIT"):
allowed, message = enforce_login_limit(user)
if not allowed:
abort(429, description=str(message))
self.verify_login(user, **kwargs)
self.login_user(user)
return self.success_response(user)
Expand Down Expand Up @@ -531,6 +536,10 @@ def post(self, **kwargs):
"""Send confirmation email."""
user = self.get_user(**kwargs)
self.verify(user)
if current_app.config.get("ACCOUNTS_SEND_CONFIRMATION_RATELIMIT"):
allowed, message = enforce_send_confirmation_limit(user)
if not allowed:
abort(429, description=str(message))
self.send_confirmation_link(user)
return self.success_response(user)

Expand Down
Loading
Loading