Have any questions?
+44 1234 567 890
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:
- Login failed
- Login was successful, the corresponding user is SOME_USER
- 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:
- Implement the net.datenwerke.security.service.authenticator.ReportServerPAM interface, and
- 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(true, someUser) // grant access, some user 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.grantAccess(user) // grant access, some user 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
public interface PAMHook extends Hook {
public void beforeStaticPamConfig(LinkedHashSet pams);
public void afterStaticPamConfig(LinkedHashSet 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)
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).