Chapter 10. Custom Authenticators PAMs

10. Custom Authenticators PAMs

ReportServer comes with a flexible authentication mechanism that allows to authenticate users using almost any conceivable authentication method. In this introductory tutorial we look at how ReportServer performs user authentication and discuss how you can plug in custom authentication schemes.

10.1. Pluggable Authentication Modules

When no user is logged in, ReportServer waits for someone to ask it to start the authentication process. Authentication is handled by ReportServer's AuthenticatorService which is triggered, for example, when a user fills in a username and password and hits the ''Login'' button. This is, however, not the only way an authentication request can be triggered. For instance, on each request ReportServer attempts an authentication without additional information, for example, to implement a single-sign-on operation without the user having to ever visit the login page. Alternatively, authentication could be triggered by a custom script thereby allowing you to implement a completely separate login page containing, for example, a two-factor authentication mechanism.

Note that ReportServer supports LDAP authentication out-of-the-box via the Ldap PAM. Details can be found in the Administration Guide.

In any case, once AuthenticatorService's authentication mechanism is triggered the following happens. The service looks for a list of installed PAMs (short for Pluggable Authentication Modules). Each registered PAM is asked whether the metadata provided for the authentication (e.g., username and password) is sufficient. For this, each PAM is required to respond with one of three possible responses:

  1. Login failed
  2. Login was successful, the corresponding user is SOME_USER
  3. I can't determine anything, leave me out of the decision process.

The decision of the AuthenticatorService is then based on the combined responses of all registered PAMs. In case any PAM opted for option 1 (Login failed) the overall authentication will fail. The same is true, if no one opted for option 2. Furthermore, authentication fails, if two modules opt for a successful login but disagree on the user.

10.2. Default PAMs

What PAMs can you choose from? The default PAM settings are made in ReportServer's external configuration file reportserver.properties. Following is the default authenticator configuration:

### authenticator configuration ##############################################

  # rs.authenticator.pams
  # configures the pluggable modules the authenticator uses to verify requests
  # multiple modules are separated by colon ":" characters
  # possible values are: 
  #   net.datenwerke.rs.authenticator.service.pam.UserPasswordPAM
  #   net.datenwerke.rs.authenticator.service.pam.UserPasswordPAMAuthoritative
  #   net.datenwerke.rs.authenticator.service.pam.IPRestrictionPAM
  #   net.datenwerke.rs.authenticator.service.pam.EveryoneIsRootPAM
  #   net.datenwerke.rs.authenticator.cr.service.pam.ChallengeResponsePAM
  #   net.datenwerke.rs.authenticator.cr.service.pam.ChallengeResponsePAMAuthoritative
  #   net.datenwerke.rs.authenticator.service.pam.ClientCertificateMatchEmailPAM
  #   net.datenwerke.rs.authenticator.service.pam.ClientCertificateMatchEmailPAMAuthoritative
  #   net.datenwerke.rs.ldap.service.ldap.pam.LdapPAM
  #   net.datenwerke.rs.ldap.service.ldap.pam.LdapPAMAuthoritative
rs.authenticator.pams = net.datenwerke.rs.authenticator.service.pam.UserPasswordPAMAuthoritative

The property rs.authenticator.pams consists of a comma separate list of one or more PAMs. In a standard installation only a single PAM is active, namely net.datenwerke.rs.authenticator.service.pam.UserPasswordPAMAuthoritative. This PAM expects two tokens, a username and a password and then attempts to match these against ReportServer's database. As you can see, the default PAMs usually come in two variants, one being called Authoritative. The difference between the two variants is how they handle the case where they cannot find the necessary information within the provided list of tokens. The authoritative version then denies access, while the non-authoritative version opts for option 3 (can't tell, let somebody else decide).

Further information on the available default PAMs is given in the Configuration Guide: https://reportserver.net/en/guides/config/chapters/configfile-reportserverproperties/

10.3. Adding Custom PAMs

While PAMs can be configured via the external configuration file, the configuration can be extended (or completely overwritten) via scripts. This allows us to bring custom authentication mechanisms into the system.

To write a custom authenticator we need to do two things:

  1. Implement the net.datenwerke.security.service.authenticator.ReportServerPAM interface, and
  2. hook into the authentication mechanism.

Let us look at these in turn.

The ReportServerPAM interface looks as follows

public interface ReportServerPAM {

   public AuthenticationResult authenticate(AuthToken[] tokens);

   public String getClientModuleName();

}

That is, we need to implement two methods: authenticate and getClientModuleName. The second one is the easier one, since there are currently not many options available here. This method tells ReportServer which module on the client side should handle the authentication. Here basically, the question is, do you want to use ReportServer's standard login page. If this is the case then you should return the String value ''net.datenwerke.rs.authenticator.client.login.pam.UserPasswordClientPAM''. Alternatively, if you want to use a custom login page, you can simply return null. Thus, a custom PAM usually takes the following form (now in Groovy).

import net.datenwerke.security.service.authenticator.ReportServerPAM

def customPAM = [
  authenticate : { tokens -> // TODO
  },
  getClientModuleName : { return 'net.datenwerke.rs.authenticator.client.login.pam.UserPasswordClientPAM' }
] as ReportServerPAM

In case we use ReportServer's default login page, the token array given to the authenticate method will consist of a single token of type net.datenwerke.rs.authenticator.client.login.dto.UserPasswordAuthToken which is a simple Java bean providing the methods getUsername() and getPassword(). (You should, however, always perform proper type checking and not expect the token array to be of a specific form.) In order to choose one of the three options (deny login, allow login, don't care) the authenticate method needs to return an object of type net.datenwerke.security.service.authenticator.AuthenticationResult. This AuthenticationResult is configured via its constructor which takes two values:

public AuthenticationResult(boolean allowed, User user);

The first value defines whether or not the PAM wants to deny access. If allowed is set to false, the user will not be able to login even if all other PAMs would be ok with that. The second value given to the AuthenticationResult is a user object indicating the user that should be logged in. Thus, we can choose between the three options by returning AuthenticationResults such as the following:

return new AuthenticationResult(false, null) // deny access
return new AuthenticationResult(false, someUser) // deny access to someUser
return new AuthenticationResult(true, someUser) // grant access, someUser should be logged in
return new AuthenticationResult(true, null) // don't care, somebody else should decide

We added static helper methods for making this easier:

return AuthenticationResult.denyAccess() // deny access
return AuthenticationResult.denyAccess(someUser) // deny access to someUser
return AuthenticationResult.grantAccess(user) // grant access, someUser should be logged in
return AuthenticationResult.dontCareAccess() // don't care, somebody else should decide

Basically, this is all you need to know to write a custom authenticator. Following is an example of a fully functional (yet not really useful) authenticator. It checks whether the provided password equals ''42''. If so, it logs in the first super user it can find. Otherwise, it chooses option 3 (don't care).

import net.datenwerke.security.service.authenticator.ReportServerPAM
import net.datenwerke.rs.authenticator.client.login.dto.UserPasswordAuthToken
import net.datenwerke.security.service.authenticator.AuthenticationResult

import net.datenwerke.security.service.usermanager.UserManagerService

def userService = GLOBALS.getInstance(UserManagerService)

def customPAM = [
  authenticate : { tokens ->
   if(tokens.length == 0 || ! tokens[0] instanceof UserPasswordAuthToken)
      return AuthenticationResult.dontCareAccess() // don't care, let somebody else decide
   if('42'.equals(tokens[0].password)){
     for(def user : userService.allUsers)
       if(user.isSuperUser())
         return AuthenticationResult.grantAccess(user) // login the super user
   }
   return AuthenticationResult.dontCareAccess() // don't care, let somebody else decide
  },
  getClientModuleName : { return 'net.datenwerke.rs.authenticator.client.login.pam.UserPasswordClientPAM' }
] as ReportServerPAM
Hooking in our custom PAM

Now that we have a custom PAM, how can we add this PAM to the list of registered PAMs? The answer is to use ReportServer's Hook infrastructure (see Chapter 8. Tapping into ReportServer for a detailed introduction). The hook we are going to implement is net.datenwerke.security.service.authenticator.hooks.PAMHook which is defined as follows:

public interface PAMHook extends Hook {

   public void beforeStaticPamConfig(LinkedHashSet pams);

   public void afterStaticPamConfig(LinkedHashSet pams);

}

The two methods allow us to adapt the list of registered PAMs, once before the static configuration (the loading of PAMs specified in the external reportserver.properties configuration file) has been done, and once after. As usual, when implementing a hook, we should instead implement the corresponding adapter (if available). Following is the combined PAM with the necessary code to hook it in. Note that we have opted to clear the registered PAMs in the afterStaticPamConfig method. This is because ReportServer's standard UserPasswordPAMs don't always play nice. In particular, when they find a username/password token and the username matches a given user but the password is incorrect they opt to deny authentication. As in our case the password will be ''incorrect'' we thus need to remove them from the list of registered PAMs.

import net.datenwerke.security.service.authenticator.ReportServerPAM
import net.datenwerke.rs.authenticator.client.login.dto.UserPasswordAuthToken
import net.datenwerke.security.service.authenticator.AuthenticationResult

import net.datenwerke.security.service.authenticator.hooks.PAMHook
import net.datenwerke.security.service.authenticator.hooks.adapter.PAMHookAdapter

import net.datenwerke.security.service.usermanager.UserManagerService

def userService = GLOBALS.getInstance(UserManagerService)

def customPAM = [
  authenticate : { tokens ->
   if(tokens.length == 0 || ! tokens[0] instanceof UserPasswordAuthToken)
      return AuthenticationResult.dontCareAccess() // don't care, let somebody else decide
   if('42'.equals(tokens[0].password)){
     for(def user : userService.allUsers)
       if(user.isSuperUser())
         return AuthenticationResult.grantAccess(user) // login the super user
   }
   return AuthenticationResult.dontCareAccess() // don't care, let somebody else decide
  },
  getClientModuleName : { return 'net.datenwerke.rs.authenticator.client.login.pam.UserPasswordClientPAM' }
] as ReportServerPAM;

def callback = [
  afterStaticPamConfig : {pams ->
    pams.clear()
    pams.add(customPAM)
  }
] as PAMHookAdapter

GLOBALS.services.callbackRegistry.attachHook('MY_CUSTOM_AUTHENTICATOR', PAMHook, callback)

Once you've executed the script, you can no longer log in with the standard username/password combination. However, once you provide ''42'' as the password, you will be logged in with a super-user account.

10.4. Installing Custom Authenticators on Startup

If you've completed development of your authenticator you could place it into the onstartup.d folder such that whenever ReportServer is booted up your authenticator becomes active immediately. This should, however, only be done after a thorough test, as otherwise you might find yourselve locked out of ReportServer.

Should you find yourself locked out of ReportServer, you can disable any scripts via the rs.scripting.disable property in the reportserver.properties configuration file. If present and set to true, scripts will not be executed and thus you can fallback on one of the standard PAMs.
10.5. Ignore Case for Usernames

As a final treat, here is another example. Recently the question was raised on our Community Forums https://forum.reportserver.net/ whether for the purpose of authentication usernames are case sensitive, and if so, if this could be changed. Indeed, by default, usernames in ReportServer are case sensitive. However, given the information covered in this tutorial it should not be too difficult to write a custom PAM that ignores the case of a provided username. The only missing piece is the information on how to validate a password against ReportServer's stored user passwords. For this we can use the net.datenwerke.rs.utils.crypto.PasswordHasher object that provides the convenience method

public boolean validatePassword(String hashedPassword, String cleartextPassword);

Following is the complete example. Note that we have opted for a slight optimization to find the user. That is, instead of looping over all users we use a single query to find the correct user.

import net.datenwerke.security.service.authenticator.ReportServerPAM
import net.datenwerke.rs.authenticator.client.login.dto.UserPasswordAuthToken
import net.datenwerke.security.service.authenticator.AuthenticationResult

import net.datenwerke.security.service.authenticator.hooks.PAMHook
import net.datenwerke.security.service.authenticator.hooks.adapter.PAMHookAdapter

import net.datenwerke.rs.utils.crypto.PasswordHasher

def passwordHasher = GLOBALS.getInstance(PasswordHasher

def customPAM = [
  authenticate : { tokens ->
   if(tokens.length == 0 || ! tokens[0] instanceof UserPasswordAuthToken)
      return AuthenticationResult.denyAccess() // don't play nice. Deny authentication

    try{
      def user = GLOBALS.getEntityManager()
      			.createQuery('FROM User WHERE lower(username) = :name')
                .setParameter('name', tokens[0].username.toLowerCase())
      			.singleResult
      if(null != user){
        if(passwordHasher.validatePassword(user.password, tokens[0].password)){
          return AuthenticationResult.grantAccess(user) // let user pass
        }
      }

    } catch(all){
      // potential logging
    }

   return AuthenticationResult.denyAccess() // don't play nice. Deny authentication
  },
  getClientModuleName : { return 'net.datenwerke.rs.authenticator.client.login.pam.UserPasswordClientPAM' }
] as ReportServerPAM

def callback = [
  afterStaticPamConfig : {pams ->
    pams.clear()
    pams.add(customPAM)
  }
] as PAMHookAdapter

GLOBALS.services.callbackRegistry.attachHook('MY_CUSTOM_AUTHENTICATOR', PAMHook, callback)

A final word of warning. ReportServer does not enforce usernames to be unique when ignoring case sensitivity. Thus, if two users are given, for example, the usernames JohnDoe and johndoe then the authentication will fail for them (the getSingleResult() method will throw a NonUniqueResultException).