How to Devleop a Custom Authentication Module - OpenIdentityPlatform/OpenAM GitHub Wiki
Source code: OpenAM Sample Custom Authentication Module
OpenAM comes complete with a wide variety of built-in custom authentication modules, but sometimes customer requirements step outside the functionality provided by the built-in modules. In these instances, it is time to consider writing your own custom authentication module. The OpenAM developer can use the source of the built-in modules as their starting point or start from scratch. If the customer requirement is a very close match to existing functionality then using a built-in module as a starting point will be a good idea. The OpenAM developer should start from scratch if the customer requirements does not match any of the built-in modules. In this HowTo we look at how to create and configure custom authentication modules.
Our sample authentication module takes username and password as input from the user. This data along with a service specific attribute and the program logic is used to authenticate the user. During this process some errors can occur which will be shown to user in detail. All texts displayed during the authentication must be easily configurable and translatable.
In short - we have following requirements
- User will be asked for name and password
- Module has one specific attribute which needs to be configured via OpenAM web-interface
- The authentication UI is configurable and translatable
- Some errors have to be displayed to user in detail
We will stick with using the basic complier in this HowTo to keep things simple. In order to compile your custom authentication module either on the command line or in your favourite IDE you will need the following OpenAM jar files.
- openam-core*.jar
In addition you will also require the J2EE Servlet API, you can obtain these from within your favourite container or here. If you use tomcat then you can find the Servlet API files in the TOMCAT_HOME/lib/servlet-api.jar file.
First important decision is to pick a name for our authentication module. We decide to call our module SampleAuth. An OpenAM custom authentication module is comprised of the following components:
- Authentication logic (SampleAuth.java) which extends the com.sun.identity.authentication.spi.AMLoginModule abstract class
- Principal (SampleAuthPrincipal.java) which implements the java.security.Principal interface
- Callbacks XML file (SampleAuth.xml) which describes the states of authentication logic and the user input needed during those states
- Service configuration XML file (amAuthSampleAuth.xml) which defines how service is configured on OpenAM side
- Authentication module properties file (amAuthSampleAuth.properties)
The custom authentication module is designed to ask the user a series of questions in order assert their identity. The order and detail of these questions is set out in the Callbacks XML file. The structure of the XML file is defined in the OPENAM_WAR/WEB-INF/Auth_Module_Properties.dtd file.
All of the built-in modules XML files are located in the OPENAM_WAR/config/auth/default directory. The OpenAM developer can use these as a starting point for their own callback XML file.
All custom authentication modules must extend from the com.sun.identity.authentication.spi.AMLoginModule abstract class. The abstract methods that must be implemented in the custom authentication module are listed below and the OpenAM Javadoc provides useful reference information.
public void init(Subject subject, Map sharedState, Map options)
public int process(Callback[] callbacks, int state)
throws LoginException
public Principal getPrincipal()
The init method is called once by OpenAM when the module is created. The process method is called into when the user submits callbacks back via the Authentication interface. The process method determines what happens next in the authentication process; success, failure or the next state (order) in the XML file. The getPrincipal method is called by OpenAM and must return an implementation of the java.security.Principal interface.
OpenAM does not reuse authentication module instances. This means that we can store information specific to the authentication process in the instance.
The OpenAM administration will require a way to configure any custom authentication modules. OpenAM mandates that all authentication modules; built-in or custom are configured in the same way by means of an OpenAM service. At the bare minimum the service should contain an authentication level attribute.
The module can find the value of these attributes in the options Map passed as a parameter to the init method. The properties file is a standard Java properties file that contains the description of the attributes that will be displayed in the console.
Simple callbacks file might look like:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE ModuleProperties PUBLIC "=//iPlanet//Authentication Module Properties XML Interface 1.0 DTD//EN"
"jar://com/sun/identity/authentication/Auth_Module_Properties.dtd">
<ModuleProperties moduleName="SampleAuth" version="1.0" >
<Callbacks length="2" order="1" timeout="600" header="Please Login" >
<NameCallback isRequired="true">
<Prompt> User Name: </Prompt>
</NameCallback>
<PasswordCallback echoPassword="false" >
<Prompt> Password: </Prompt>
</PasswordCallback>
</Callbacks>
</ModuleProperties>
This file defines authentication module SampleAuth with one possible state (order 1) in which 2 pieces of information are asked from the user. All strings that are used to draw the UI are typed into this file.
According to our requirements we need a little-bit more sophisticated callbacks file. We need dynamic text and we need error state. Following callbacks file achieves this:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE ModuleProperties PUBLIC "=//iPlanet//Authentication Module Properties XML Interface 1.0 DTD//EN"
"jar://com/sun/identity/authentication/Auth_Module_Properties.dtd">
<ModuleProperties moduleName="SampleAuth" version="1.0" >
<Callbacks length="0" order="1" timeout="600" header="#WILL NOT BE SHOWN#" />
<Callbacks length="2" order="2" timeout="600" header="#WILL BE SUBSTITUTED#" >
<NameCallback isRequired="true">
<Prompt>#USERNAME#</Prompt>
</NameCallback>
<PasswordCallback echoPassword="false" >
<Prompt>#PASSWORD#</Prompt>
</PasswordCallback>
</Callbacks>
<Callbacks length="1" order="3" timeout="600" header="#WILL BE SUBSTITUTED#" error="true" >
<NameCallback>
<Prompt>#THE DUMMY WILL NEVER BE SHOWN#</Prompt>
</NameCallback>
</Callbacks>
</ModuleProperties>
Few comments:
- We need order 1 with 0 callbacks to change all UI strings for following orders. The trial-and-error shows that this dummy state is necessary to achieve dynamic UI-strings.
- The placeholders '#FOO BAR#' have no real meaning, this is just to show that those texts get replaced.
- The order 3 has attribute error set to "true". If the authentication module state machine reaches this order then the authentication has failed. The NameCallback is actually not used and not displayed to user, but the callbacks with length at least 1 is necessary, otherwise the OpenAM won't let us to substitute the header. So it's yet another hack forced upon us.
package com.forgerock.openam.examples;
import com.sun.identity.shared.datastruct.CollectionHelper;
import com.sun.identity.authentication.spi.AMLoginModule;
import com.sun.identity.authentication.spi.AuthLoginException;
import com.sun.identity.authentication.spi.InvalidPasswordException;
import com.sun.identity.authentication.util.ISAuthConstants;
import com.sun.identity.shared.debug.Debug;
import java.security.Principal;
import java.util.Map;
import javax.security.auth.Subject;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.NameCallback;
import javax.security.auth.callback.PasswordCallback;
import javax.security.auth.login.LoginException;
import java.util.ResourceBundle;
import com.sun.identity.shared.locale.AMResourceBundleCache;
public class SampleAuth extends AMLoginModule {
// Name for the debug-log
private final static String DEBUG_NAME = "SampleAuth";
// Name of the resource-bundle
private final static String amAuthSampleAuth = "amAuthSampleAuth";
// usernames for authentication logic
private final static String USERNAME = "test";
private final static String ERROR_1_NAME = "test1";
private final static String ERROR_2_NAME = "test2";
// orders defined in the callbacks file
private final static int STATE_BEGIN = 1;
private final static int STATE_AUTH = 2;
private final static int STATE_ERROR = 3;
private final static Debug debug = Debug.getInstance(DEBUG_NAME);
private Map options;
private ResourceBundle bundle;
public SampleAuth() {
super();
}
@Override
// In this method we store service attributes and localized properties
// for later use
public void init(Subject subject, Map sharedState, Map options) {
if (debug.messageEnabled()) {
debug.message("SampleAuth::init");
}
this.options = options;
bundle = amCache.getResBundle(amAuthSampleAuth, getLoginLocale());
}
@Override
public int process(Callback[] callbacks, int state) throws LoginException {
if (debug.messageEnabled()) {
debug.message("SampleAuth::process state: " + state);
}
switch (state) {
case STATE_BEGIN:
// No time wasted here - simply modify the UI and
// proceed to next state
substituteUIStrings();
return STATE_AUTH;
case STATE_AUTH:
// Get data from callbacks. Refer to callbacks xml file!
NameCallback nc = (NameCallback) callbacks[0];
PasswordCallback pc = (PasswordCallback) callbacks[1];
String username = nc.getName();
String password = new String(pc.getPassword());
// First errorstring is stored in "sampleauth-error-1" property
if (username.equals(ERROR_1_NAME)) {
setErrorText("sampleauth-error-1");
return STATE_ERROR;
}
// Second errorstring is stored in "sampleauth-error-2" property
if (username.equals(ERROR_2_NAME)) {
setErrorText("sampleauth-error-2");
return STATE_ERROR;
}
if (username.equals(USERNAME) && password.equals("password")) {
return ISAuthConstants.LOGIN_SUCCEED;
}
throw new InvalidPasswordException("password is wrong", USERNAME);
case STATE_ERROR:
return STATE_ERROR;
default:
throw new AuthLoginException("invalid state");
}
}
@Override
public Principal getPrincipal() {
return new SampleAuthPrincipal(USERNAME);
}
private void setErrorText(String err) throws AuthLoginException
{
// Receive correct string from properties and substitute the
// header in callbacks order 3.
substituteHeader(STATE_ERROR, bundle.getString(err));
}
private void substituteUIStrings() throws AuthLoginException
{
// Get service specific attribute configured in OpenAM
String ssa = CollectionHelper.getMapAttr(options, "sampleauth-service-specific-attribute");
// Get property from bundle
String new_hdr = ssa + " " + bundle.getString("sampleauth-ui-login-header");
substituteHeader(STATE_AUTH, new_hdr);
Callback[] cbs_phone = getCallback(STATE_AUTH);
replaceCallback(STATE_AUTH, 0,
new NameCallback(bundle.getString("sampleauth-ui-username-prompt")));
replaceCallback(STATE_AUTH, 1,
new PasswordCallback(bundle.getString("sampleauth-ui-password-prompt"), false));
}
}
You will notice the code above allows the user "test" to log in with a password of "password". In order for this to work you will need to create a user called test in the OpenAM administration pages. This is because the code goes on to retrieve the user profile for the user, and if one doesn't exist, it will be this subsequent step that will fail and prevent you from logging in.
In the OpenAM console, logged in as amadmin, select "Access Control", Top Level Realm, "Subjects" and click "New…"
Here is the code for the principal implementation
package com.forgerock.openam.examples;
import java.io.Serializable;
import java.security.Principal;
/**
*
* @author Steve Ferris [email protected]
*/
public class SampleAuthPrincipal implements Principal, Serializable {
private String name;
private final static String CLASSNAME = "SampleAuthPrincipal";
private final static String COLON = " : ";
public SampleAuthPrincipal(String name) {
if (name == null) {
throw new NullPointerException("illegal null input");
}
this.name = name;
}
/**
* Return the LDAP username for this <code> SampleAuthPrincipal </code>.
*
* <p>
*
* @return the LDAP username for this <code> SampleAuthPrincipal </code>
*/
public String getName() {
return name;
}
/**
* Return a string representation of this <code> SampleAuthPrincipal </code>.
*
* <p>
*
* @return a string representation of this <code>TestAuthModulePrincipal</code>.
*/
@Override
public String toString() {
return new StringBuilder().append(CLASSNAME).append(COLON).append(name).toString();
}
/**
* Compares the specified Object with this <code> SampleAuthPrincipal </code>
* for equality. Returns true if the given object is also a
* <code> SampleAuthPrincipal </code> and the two SampleAuthPrincipal
* have the same username.
*
* <p>
*
* @param o Object to be compared for equality with this
* <code> SampleAuthPrincipal </code>.
*
* @return true if the specified Object is equal equal to this
* <code> SampleAuthPrincipal </code>.
*/
@Override
public boolean equals(Object o) {
if (o == null) {
return false;
}
if (this == o) {
return true;
}
if (!(o instanceof SampleAuthPrincipal)) {
return false;
}
SampleAuthPrincipal that = (SampleAuthPrincipal) o;
if (this.getName().equals(that.getName())) {
return true;
}
return false;
}
/**
* Return a hash code for this <code> SampleAuthPrincipal </code>.
*
* <p>
*
* @return a hash code for this <code> SampleAuthPrincipal </code>.
*/
@Override
public int hashCode() {
return name.hashCode();
}
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE ServicesConfiguration
PUBLIC "=//iPlanet//Service Management Services (SMS) 1.0 DTD//EN"
"jar://com/sun/identity/sm/sms.dtd">
<ServicesConfiguration>
<Service name="iPlanetAMAuthSampleAuthService" version="1.0">
<Schema
serviceHierarchy="/DSAMEConfig/authentication/iPlanetAMAuthSampleAuthService"
i18nFileName="amAuthSampleAuth"
revisionNumber="10"
i18nKey="sampleauth-service-description"
resourceName="iPlanetAutSampleAuthService">
<Organization>
<AttributeSchema name="iplanet-am-auth-sampleauth-auth-level"
type="single"
syntax="number_range" rangeStart="0" rangeEnd="2147483647"
i18nKey="a500"
resourceName="iplanetamauthsampleautlevel">
<DefaultValues>
<Value>1</Value>
</DefaultValues>
</AttributeSchema>
<AttributeSchema name="sampleauth-service-specific-attribute"
type="single"
syntax="string"
validator="no"
i18nKey="a501"
resourceName="sampleauthservicespecattr">
<DefaultValues>
<Value></Value>
</DefaultValues>
</AttributeSchema>
<SubSchema name="serverconfig" inheritance="multiple" resourceName="USE-PARENT">
<AttributeSchema name="iplanet-am-auth-sampleauth-auth-level"
type="single"
syntax="number_range" rangeStart="0" rangeEnd="2147483647"
i18nKey="a500"
resourceName="iplanetamauthsampleautlevel">
<DefaultValues>
<Value>1</Value>
</DefaultValues>
</AttributeSchema>
<AttributeSchema name="sampleauth-service-specific-attribute"
type="single"
syntax="string"
validator="no"
i18nKey="a501"
resourceName="sampleauthservicespecattr">
<DefaultValues>
<Value></Value>
</DefaultValues>
</AttributeSchema>
</SubSchema>
</Organization>
</Schema>
</Service>
</ServicesConfiguration>
- The service must have a name: 'iPlanetAMAuth' + modulename + 'Service'
- The service must have a description
- i18nFileName refers to the properties file and i18nKey to the key in the properties file
- sampleauth-service-specific-attribute configurable via OpenAM is defined here
The 'iplanet-am-auth-sampleauth-auth-level' is the obligatory authentication level attribute. Here naming is very important. The name of the attribute must be: 'iplanet-am-auth-' + lowercase(modulename) + '-auth-level'. If the attribute name does not follow the pattern your authentication module will not function the way you want it to. (There is also a second naming convention - please refer to the source code (wink) )
sampleauth-service-description=Sample Authentication Module
a500=Authentication Level
a501=Service Specific Attribute
sampleauth-ui-login-header=Login
sampleauth-ui-username-prompt=User Name:
sampleauth-ui-password-prompt=Password:
sampleauth-error-1=Error 1 occured during the authentication
sampleauth-error-2=Error 2 occured during the authentication
It's a simple properties file. Be careful to match all the identifier.
Next we need to compile the custom authentication module.
$ javac -d ../classes/ -classpath lib/servlet-api.jar:lib/amserver.jar:lib/opensso-sharedlib.jar:lib/opensso.jar SampleAuth.java SampleAuthPrincipal.java
This will create our class file that we need to install into OpenAM. Next we should package the classes files up into a jar file.
$ cd classes
$ jar cf ../sampleauth.jar .
###Copy the jar file
We copy the jar file into the WEB-INF/lib directory within the openam web application deployed into Tomcat.
$ cp sampleauth.jar $TOMCAT_HOME/webapps/openam/WEB-INF/lib/
We copy the properties file into the WEB-INF/classes directory within the openam web application deployed into Tomcat.
$ cp amAuthSampleAuth.properties $TOMCAT_HOME/webapps/openam/WEB-INF/classes/
We copy the properties file into the config/auth/default directory within the openam web application deployed into Tomcat.
$ cp SampleAuth.xml $TOMCAT_HOME/webapps/openam/config/auth/default
Use the ssoadm command to register the service and the module with OpenAM. The service can only be registered with OpenAM using ssoadm.
$ ssoadm create-svc -u amadmin -f pwdfile -X /path/to/amAuthSampleAuth.xml
$ ssoadm register-auth-module -u amadmin -f pwdfile -a com.forgerock.openam.examples.SampleAuth
Note the service definition file is only needed for creating the service. When calling ssoadm create-svc, be sure to pass the full path to your service definition file.
Also following subcommands might be useful to you during the testing: delete-auth-cfgs, delete-auth-instances, delete-svc, list-auth-cfgs, list-auth-instances, remove-svc-realm, show-auth-modules, show-realm-svcs, unregister-auth-module, update-svc
It is a good idea at this point to restart Tomcat (or whatever container you are using) to ensure the Authentication Module and service definition can be loaded.
Log into the Console and navigate to Access Control >> Realm >> Authentication and click New under Authentication modules. Name your module and select the custom authentication module type from the list. Click OK. You should now see your custom module listed, you can select it and configure it as appropriate.
The final step is to test out your custom authentication module. To do this you just need to login successfully to the realm where the custom authentication module is configured. This URL should do the trick:
http://hostname.domainname/openam/UI/Login?module=auth_module_name
You should be presented with the login screen and asked for the credentials required by your custom authentication module.