diff --git a/invenio_accounts/config.py b/invenio_accounts/config.py index 9defe611..81a931a2 100644 --- a/invenio_accounts/config.py +++ b/invenio_accounts/config.py @@ -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 +"""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", diff --git a/invenio_accounts/ext.py b/invenio_accounts/ext.py index 90b51c25..5e9b34bc 100644 --- a/invenio_accounts/ext.py +++ b/invenio_accounts/ext.py @@ -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, @@ -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( diff --git a/invenio_accounts/forms.py b/invenio_accounts/forms.py index 5ce25c53..1762d2b8 100644 --- a/invenio_accounts/forms.py +++ b/invenio_accounts/forms.py @@ -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 @@ -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 @@ -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 diff --git a/invenio_accounts/limiter.py b/invenio_accounts/limiter.py new file mode 100644 index 00000000..4eb55362 --- /dev/null +++ b/invenio_accounts/limiter.py @@ -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", + ) diff --git a/invenio_accounts/templates/semantic-ui/invenio_accounts/login_user.html b/invenio_accounts/templates/semantic-ui/invenio_accounts/login_user.html index c3407d7f..6803c20e 100644 --- a/invenio_accounts/templates/semantic-ui/invenio_accounts/login_user.html +++ b/invenio_accounts/templates/semantic-ui/invenio_accounts/login_user.html @@ -24,6 +24,7 @@