Overview

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 at log in to the site (the action to protect) to maximum 10 times (the maximum number of attempts) per 15 minutes (the duration) given requests from the same IP 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.

Usage

Registering your Attempt Rules

Once the plugin is properly installed, the first thing to do is to register your attempt rules.

An AttemptRule contains three things:

  • The name of the action to protect. For example "login", "confirm order", etc.
  • The maximum number of attempts allowed for a duration. For example "3".
  • The 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 :

  • 14-15 : The init method is called as soon as the controller instance is available.
  • 16 : Using the AttemptsManager, we start registering an Attempt Rule for the action to protect "login".
  • 17 : This rule will allow 10 attempts...
  • 18 : ... for a duration of 15 minutes.

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);

Validating an attempt

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 :

  • 5 : The "loginPost" method is the route handler managing a login request.
  • 6-7 : We call the "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!
  • 8 : We specify the 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.
  • 9 : You call the isMaxReached() method on the Attempt object to see if the associated action should be allowed or not!
  • 10-12 : If the action should be denied, you can throw an Exception, display a warning message to the user, etc.

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!

Incrementing the number of attempts

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.

Dependencies

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.

Installation

The plugin itself

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())
        // ...

The repository implementation

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);

Configurations

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.

Options:

  • 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.