Introduction
Authentication is the process of identity verification. A user should provide some information as an identity that the system understands so as to allow the user to login the system. This document covers one of Shiro's authentication mechanism that can be used for a java RESTful application.
Shiro
Apache Shiro is a java security framework that offers solution for authentication, authorization and session management for an application. Since RESTful application are stateless, we will not deal with sessions. For every request URL, the user is authenticated. Four basic terms that Shiro uses are:
(1) Subject: refers to the user who is using the system. It can be human-being, another application, cron job etc.
(2) Principals: refers to attributes that identify the subject, like username first name, last name, ssn
(3) Credentials: refers to secret data like password, certificates etc.
(4) Realms: this is Shiro's security specific DAO which talks to the application's back end datasource or another DAO.
Shiro configuration file (shiro.ini)
During the application deployment, Shiro loads its configuration file (shiro.ini). They call it as the initialization file where the Realm, Credential matcher, authentication filter and few more settings that are to be used in the application are configured.
shiro.ini
[main]
authc.loginUrl = /web/login.jsp
customRealm = com.app.security.shiro.realm.CustomRealm
credentialsMatcher = com.app.security.shiro.checker.CustomCredentialsMatcher
credentialsMatcher.hashAlgorithmName = SHA-512
credentialsMatcher.hashIterations = 1024
# base64 encoding, not hex since it requires less space than hex
credentialsMatcher.storedCredentialsHexEncoded = false
customRealm.credentialsMatcher = $credentialsMatcher
# customRealm.dataSourceName should be the same that is used in "persistence.xml"
customRealm.dataSourceName = java:jboss/datasources/appDS
securityManager.realms = $customRealm
[urls]
# authc means the default FormAuthenticationFilter used for FORM level login
/web/login.jsp = authc
# REST URL authentication. authcBasic means the default BasicHttpAuthenticationFilter
# "rest": HttpMethodPermissionFilter, to be used in future for authorization
/rest/** = authcBasic, rest
Shiro Filter
Shiro provides a request filter called ShiroFilter which needs to be added in our web.xml such that all the request to the application pass through this filter before being processed by the server. The Shiro filter internally forwards the request to the Authentication filter that we have configured in shiro.ini file. For our application, we used 'BasicHttpAuthenticationFilter' (which is marked as 'authcBasic' in shiro.ini). This filter internally uses the Realm DAO and the CredentialsMatcher to validate the credentials for authentication. If the user is successfully validated, then the request is forwarded to the application's REST layer to process further.
web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
<listener>
<listener-class>org.apache.shiro.web.env.EnvironmentLoaderListener</listener-class>
</listener>
<filter>
<filter-name>ShiroFilter</filter-name>
<filter-class>org.apache.shiro.web.servlet.ShiroFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>ShiroFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<session-config>
<session-timeout>30</session-timeout>
</session-config>
</web-app>
Realm and CredentialMatcher
The principal (username) and credential (password) are retrieved from the "Authorization" URL header attribute. They are base64 encoded and are given in the following format, "Basic YWRtaW46YWRtaW4=" where 'YWRtaW46YWRtaW4=' is the base 64 encode of username:password.
The ShiroFilter internally constructs the AuthenticationToken using the username & password. The BasicAuthenticationFilter internally validates this token with the user authentication information in the application database. This is done using CustomRealm and CustomCredentialsMatcher.
CustomRealm is a custom Realm which has its own implementation to fetch the authentication information of the respective user from the application database. We use just plain JDBC to fetch the user record matching the username in the Authentication token. Plain JDBC has been used due to a Shiro limitation which is given below. Finally the AuthenticationToken and the user info fetched from database are passed to the CustomCredentialsMatcher for validation.
Shiro limitation: Apache Shiro is unaware of CDI injection in Realm. The injected UserDAO was not instantiated during CustomRealm object instantiation. There are a couple of tickets in Shiro's queue for this. Refer: https://issues.apache.org/jira/browse/SHIRO-337, https://issues.apache.org/jira/browse/SHIRO-422. Hence as a workaround, currently CustomRealm extends JdbcRealm and we fetch the user object matching username through direct JDBC queries. In future if Shiro resolves the above tickets, we can remove the plain JDBC stuff and enable the injected UserDAO code.
CustomRealm.java
public class CustomRealm extends JdbcRealm { //JdbcRealm extends AuthorizingRealm and AuthenticatingRealm
private static final Logger log = Logger.getLogger(CustomRealm.class);
protected static final String DEFAULT_AUTHENTICATION_QUERY = "SELECT user_name, password, password_salt "
+ "FROM user WHERE user_name = ?";
// @Inject protected UserDAO userDAO;
public CustomRealm() {
super();
setName("CustomRealm");
CustomCredentialsMatcher credentialMatcher = new CustomCredentialsMatcher();
setCredentialsMatcher(credentialMatcher);
log.info("CustomRealm instantiated!");
}
protected String dataSourceName; // java:jboss/datasources/appDS - configured in shiro.ini
public String getDataSourceName() {
return dataSourceName; // java:jboss/datasources/appDS
}
public void setDataSourceName(String dataSourceName) {
this.dataSourceName = dataSourceName;
this.dataSource = getDataSourceFromJNDI(dataSourceName); // setting datasource to JdbcRealm.datasource
}
private DataSource getDataSourceFromJNDI(String jndiDataSourceName) {
try {
InitialContext ic = new InitialContext();
return (DataSource) ic.lookup(jndiDataSourceName);
} catch (NamingException e) {
log.error("JNDI error while retrieving " + jndiDataSourceName, e);
throw new AuthorizationException(e);
}
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
throws AuthenticationException {
log.info("Do Shiro doGetAuthenticationInfo...");
//identify account to log to
String username = String.valueOf(token.getPrincipal());
checkNotNull(username, "Null usernames are not allowed by this realm.");
log.info("---------------------------------------------------------------------");
log.info("Principal: " + String.valueOf(token.getPrincipal())
+ " - Credentials: " + String.valueOf(token.getCredentials()));
log.info("---------------------------------------------------------------------");
String hashedPassword = null;
ByteSource credentialsSalt = null;
if("admin".equalsIgnoreCase(username)) {
hashedPassword = "admin";
} else {
IUser user = null;
// user = userDAO.findByUserName(username);
Connection conn = null;
try {
conn = dataSource.getConnection();
user = getUserByUserName(conn, username);
} catch (SQLException e) {
throw new AuthenticationException("Unable to fetch user by username ["
+ username + "].", e);
} finally {
JdbcUtils.closeConnection(conn);
}
checkNotNull(user, "No account found for user [" + username + "]");
hashedPassword = user.getPassword();
credentialsSalt = new SimpleByteSource(Base64.decode(user.getPasswordSalt()));
}
// SimpleAuthenticationInfo implements SaltedAuthenticationInfo
return new SimpleAuthenticationInfo(token.getPrincipal(), hashedPassword, credentialsSalt, getName());
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection arg0) {
// TODO Auto-generated method stub
return null;
}
private IUser getUserByUserName(Connection conn, String username)
throws SQLException {
PreparedStatement ps = null;
ResultSet rs = null;
IUser user = null;
try {
ps = conn.prepareStatement(DEFAULT_AUTHENTICATION_QUERY);
ps.setString(1, username);
rs = ps.executeQuery();
// Loop over results - although we are only expecting one result,
// since usernames should be unique
boolean foundResult = false;
while (rs.next()) {
// Check to ensure only one row is processed
if (foundResult) {
throw new AuthenticationException(
"More than one user row found for user ["
+ username + "]. Usernames must be unique."); // TODO work on this warning
}
user = new User();
user.setUserName(rs.getString("user_name"));
user.setPassword(rs.getString("password"));
user.setPasswordSalt(rs.getString("password_salt"));
foundResult = true;
}
} finally {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(ps);
}
return user;
}
private void checkNotNull(Object reference, String message) {
if (reference == null) {
throw new AuthenticationException(message);
}
}
}
CustomCredentialsMatcher.java
public class CustomCredentialsMatcher extends HashedCredentialsMatcher {
private static final Logger log = Logger.getLogger(CustomCredentialsMatcher.class);
private static final SHIRO_CREDENTIAL_HASH_INTERATION = 1024;
public CustomCredentialsMatcher() {
super();
log.info("CustomCredentialsMatcher instantiated!");
setHashAlgorithmName(Sha512Hash.ALGORITHM_NAME);
setHashIterations(SHIRO_CREDENTIAL_HASH_INTERATION);
setStoredCredentialsHexEncoded(false); // Base64-encode, not hex-encode
}
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
log.info("Shiro doCredentialsMatch...");
log.info("AuthenticationToken.credentials: " + new String((char[]) token.getCredentials()));
log.info("AuthenticationToken.principals: " + token.getPrincipal());
log.info("AuthenticationInfo.credentials: " + info.getCredentials());
log.info("AuthenticationInfoprincipals: " + info.getPrincipals());
if("admin".equalsIgnoreCase(String.valueOf(token.getPrincipal()))) {
return true;
} else {
// verifies by comparing the credential in token and info
return super.doCredentialsMatch(token, info);
}
}
}
Schema design: User (user)
- user_name : username to login
- password : password to login
- passwordSalt : a secure random number used as a salt while encrypting/decrypting the password.
It is insecure to store the password as plain string in the database. Hence we encrypt the password using Shiro's Sha512Hash algorithm, also a password salt is used for this to make it more secure. Note that the hashing algorithm (Sha512Hash) used for this password crypt is configured in shiro.ini.
UserService.java
public class UserService {
@Inject UserDAO userDAO;
public void createUser(UserBean userBean) {
String id = // generate a uuid
userBean.setId(id);
if(userBean.getPassword != null) {
// We'll use a Random Number Generator to generate salts. This is much more secure
// than using a username as a salt or not having a salt at all.
RandomNumberGenerator rng = new SecureRandomNumberGenerator();
Object salt = rng.nextBytes();
//hash the plain-text password with the random salt and multiple
//iterations and then Base64-encode the value (requires less space than Hex)
String final SHIRO_CREDENTIAL_HASH_INTERATION = 1024;
String hashedPasswordBase64 = new Sha512Hash(this.password, salt,
SHIRO_CREDENTIAL_HASH_INTERATION).toBase64();
userBean.setPassword(hashedPasswordBase64);
userBean.setPasswordSalt(salt.toString());
} else {
throw new ApplicationRuntimeException("password is null");
}
userDAO.createUser(userBean);
}
}