This plugin lets you limit the number of attempts users can make trying to perform some actions.
For each kind of action you want to protect, you specify the number of allowed attempts, a duration and some criteria. For example:
Limit the number of attempts atlog in to the site
(the action to protect) to maximum10 times
(the maximum number of attempts)per 15 minutes
(the duration) given requests from the sameIP address
(the criteria).
With this rule in place, multiple requests coming from the same IP address and trying to log in more than 10 times in 15 minutes would be blocked.
Once the plugin is properly installed, the first thing to do is to register your attempt rules.
An AttemptRule contains three things:
name
of the action to protect. For example "login", "confirm order", etc.
maximum number of attempts
allowed for a duration. For example "3".
duration
to consider. For example "15 minutes", "2 days", etc.
You register those rules anywhere in your code, using the AttemptsManager provided by the plugin. In general, you may want to register a rule in the same class where the action to protect occurs (in a controller, for example). You also probably want to perform those registrations in an init method so the rules are registered as soon as the application starts:
public class LoginController { private final AttemptsManager attemptsManager; protected AttemptsManager getAttemptsManager() { return this.attemptsManager; } @Inject public LoginController(AttemptsManager attemptsManager) { this.attemptsManager = attemptsManager; } @Inject protected void init() { getAttemptsManager().registerAttempRule("login", 10, Duration.of(15, ChronoUnit.MINUTES)); } //... }
Explanation :
init
method
is called as soon as the controller instance is available.
AttemptsManager
,
we start registering an Attempt Rule
for the action to protect
"login
".
Note that you can also use the AttemptFactory to create an AttemptRule instance by yourself:
@Injected private AttemptFactory attemptFactory; //... AttemptRule changePasswordAttemptRule = attemptFactory.createAttemptRule("changePassword", 5, Duration.of(1, ChronoUnit.HOURS)); getAttemptsManager().registerAttempRule(changePasswordAttemptRule);
Let's continue with our "login" example.
When a login request enters the route handler
in
charge of handling it, we call the
attempt(...)
method of the AttemptsManager
object. This method returns an
Attempt instance
representing the current attempt.
With this Attempt
instance, we're able to know if the associated action
(here "trying to log in on the site") must be allowed or be denied:
public class LoginController { // ... public void loginPost(AppRequestContext context) { Attempt attempt = getAttemptsManager().attempt("login", AttemptCriteria.of("ip", context.request().getIp())); if (attempt.isMaxReached()) { // Attempt denied! // Manage this as you want : throw an exception, display // a warning message, etc. } } }
Explanation :
attempt(...)
"
method of the AttemptsManager
object. We first specify the name of the
action we are interested in ("login"). This name must match the name
used in a registered AttemptRule
!
criteria
we
want to use to validate the request. Here, we use the IP address of the request.
The AttemptCriteria#of(...)
method is an easy way to create such criteria
.
Attempt
object to see if the associated action should be allowed or not!
Note that you can specify as many criteria
as you want! For example, you may want to
limit the number of attempts for the "send contact message" action not only by IP address, but also
by user id:
Attempt attempt = getAttemptsManager().attempt("send contact", AttemptCriteria.of("ip", context.request().getIp()), AttemptCriteria.of("userId", user.getId())); if (attempt.isMaxReached()) { // ...
The only important thing is that you use consistent names for your criteria, as the plugin will use those to find the correct number of attempts made with them! But, otherwise, the plugin doesn't care: it will use any criteria names and values you provide at runtime to group attempts together in order to determine if the maximum was reached or not.
Also note that if you try to validate an attempt using the name of an action which isn't found in any
registered AttemptRules
, the attempt.isMaxReached()
method
will always return true
, so the action will be denied!
By default, the number of attempts will be automatically incremented, when you call
the attempt(...)
method. This means that without any extra code,
attempt.isMaxReached()
will automatically become true when too many
attempts are made.
But sometimes you may need more control. You may want to manage by yourself when to increment the number of attempts! For example, you may want to allow an action to be performed as many times as a user want, as long as he provides a correct password. If the user always sends the correct password, each time, he can perform the action as many times as he wants. In that situation, you don't want the attempts to be automatically incremented, since the action would be denied after a while...
You can configure how the auto-increment is done (or not done) by passing a
AttemptsAutoIncrementType
parameter when calling the .attempt(...)
method:
Attempt attempt = getAttemptsManager().attempt("login", AttemptsAutoIncrementType.NEVER, AttemptCriteria.of("ip", context.request().getIp())); if (attempt.isMaxReached()) { // ...
This parameter can be:
ALWAYS
: the method will always automatically increment the number of attempts
(this is the default).
NEVER
: the method will never increment the number of attempts.
IF_MAX_REACHED
: the method will only increment the number of attempts
automatically if the current attempt is denied.
IF_MAX_NOT_REACHED
: the method will only increment the number of attempts
automatically if the current attempt is allowed.
If you don't let the .attempt(...)
method increment the number of attempts, you
are responsible to do it by yourself. You do so by calling
incrementAttemptsCount()
on the Attempt
instance. For example:
Attempt attempt = getAttemptsManager().attempt("login", AttemptsAutoIncrementType.NEVER, AttemptCriteria.of("ip", context.request().getIp())); if (attempt.isMaxReached()) { // ... } // We only increment the number of attempts if // the password provided by the user is invalid! if(!passwordValid) { attempt.incrementAttemptsCount(); } //...
Note that even if .incrementAttemptsCount()
is called multiple times,
the attempts will only be incremented once.
The Spincast Attempts Limiter plugin depends on the Spincast Scheduled Tasks plugin, a plugin which is not
provided by default by the spincast-default
artifact. This dependency plugin will be automatically installed,
you don't need to install it by yourself in your application (but you can).
Just don't be surprised if you see transitive dependencies being added to your application!
Also, note that if you want to use the provided repository implementation example, as is, you will need to install the Spincast JDBC plugin.
1. Add this Maven artifact to your project:
<dependency> <groupId>org.spincast</groupId> <artifactId>spincast-plugins-attempts-limiter</artifactId> <version>2.2.0</version> </dependency>
2. Add an instance of the SpincastAttemptsLimiterPlugin plugin to your Spincast Bootstrapper:
Spincast.configure() .plugin(new SpincastAttemptsLimiterPlugin()) // ...
This plugin is agnostic on what database is used to save the information about the attempts. Therefore you need to bind a custom implementation of the SpincastAttemptsLimiterPluginRepository in the Guice context to specify how it should be done.
There are four methods to implement in that repository:
void saveNewAttempt(String actionName, AttemptCriteria... criterias)
Called by the plugin to save a new attempt, with its associated criteria.
Map<String,Integer> getAttemptsNumberPerCriteriaSince(String actionName, Instant sinceDate, AttemptCriteria... criterias)
Called to get a Map consisting of the criteria
and the number of
attempts associated with them currently in the database, since the specified date
.
void deleteAttempts(String actionName, AttemptCriteria... criterias)
Called to delete all attempts saved in the database, given the action name
and a set of criteria
.
void deleteAttemptsOlderThan(String actionName, Instant date)
Called to delete the attempts for the action name
and that are older
than the specified date
.
Those methods are not totally trivial to implement if you don't know what they must do exactly. But you can use this as a reference: repository implementation example [GitHub].
This implementation uses a H2 database (the database we use to test the plugin).
We also tested this implementation using PostgreSQL, and very few
modifications were required
("TIMESTAMPTZ
" instead of "TIMESTAMP WITH TIME ZONE
" for the "creation_date " column was one, for example).
In addition to the methods required by the repository interface, this example also contains the SQL required to
create the "attempts
" table and its indexes, in the
createAttemptTable()
method.
When your implementation of the repository is ready, you bind it in your application's Guice module:
bind(SpincastAttemptsLimiterPluginRepository.class) .to(AppAttemptsLimiterPluginRepository.class) .in(Scopes.SINGLETON);
The configuration interface for this plugin is SpincastAttemptsLimiterPluginConfig. To change the default configurations, you can bind an implementation of that interface, extending the default SpincastAttemptsLimiterPluginConfigDefault implementation if you don't want to start from scratch.
boolean isValidationEnabled()
To enable/disable the validation.
By default it is enabled except in development mode
(when SpincastConfig#isDevelopmentMode
is true
)
AttemptsAutoIncrementType getDefaultAttemptAutoIncrementType()
The default type auto-increment performed by the attempt(...) method.
Defaults to ALWAYS
.
boolean isAutoBindDeleteOldAttemptsScheduledTask()
Should a scheduled task to delete old attempts in the database be automatically registered? If you disable this option, you are responsible to clean up your database of old attempts entries.
Defaults to true
.
int getDeleteOldAttemptsScheduledTaskIntervalMinutes()
The number of minutes between two launches of the scheduled task that will clean the database from old attempts, if isAutoBindDeleteOldAttemptsScheduledTask() is enabled.
Defaults to 10
.