Greetings! We did a small thing and thought it may be of interest to a broader audience. One of our clients insisted in additional security using a second factor authorization, using ENTRUST.
ENTRUST application would run on the users phone and generate a One Time Token (8 digits), valid for 30 seconds. When authenticating with username and password, this token must be passed and verified. Else authentication shall be denied. ActiveDirectory and JDBC Realms are in use. To make this happen, we have implemented a `TokenAware` interface, which the common realms can implement. It takes care of the 2FA part and I have the examples attached. 1) in the shiro.ini change the ActiveDirectoryRealm into TokenAwareActiveDirectoryRealm 2) then set the properties tokenRegex, soapUrl, soapUser, soapPass, soapWs # define the 2FA Token as 8 trailing digits realm.tokenRegex = (.+)(\d{8})$ realm.soapUrl = https://172.20.236.28:7851/entrust2/service realm.soapUser = .... realm.soapPass = .... realm.soapWs = http://ws.waei.uba.com/ 3) then the User will need to append the 8 digit Entrust Token to the regular password, e.g. username: andr...@manticore-projects.com password: MySecret25 would become username: andr...@manticore-projects.com password: MySecret2543218765 (With "43218765" obtained from the Entrust App, which will be valid one time and for 30 seconds only.) This works like a charm for us and has the advantage, that in can be switched on/off just by changing the realm and works with existing Forms and Dialogs (not depending on an extra pop-up or Token field). The Code is attached, please do let me know what you think and if there is any interest to commit this into the project. All the best and cheers Andreas
package com.manticore.common.auth; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.UsernamePasswordToken; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.w3c.dom.Document; import org.w3c.dom.NodeList; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.HashMap; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; public interface TokenAware { Logger log = LoggerFactory.getLogger(TokenAware.class); Map<String, String> properties = new HashMap<>(); // Setters to allow injection from Shiro.ini default void setTokenRegex(String regex) { if (log.isTraceEnabled()) { log.trace("Apply Token Pattern:" + regex); } properties.put("tokenPattern", regex); } default void setSoapUrl(String soapUrl) { if (log.isTraceEnabled()) { log.trace("Apply SOAP Url:" + soapUrl); } properties.put("soapUrl", soapUrl); } default void setSoapUser(String soapUser) { if (log.isTraceEnabled()) { log.trace("Apply SOAP User:" + soapUser); } properties.put("soapUser", soapUser); } default void setSoapPass(String soapPass) { if (log.isTraceEnabled()) { log.trace("Apply SOAP Password (hidden)."); } properties.put("soapPass", soapPass); } default void setSoapWs(String soapWs) { if (log.isTraceEnabled()) { log.trace("Apply SOAP WS:" + soapWs); } properties.put("soapWs", soapWs); } default String[] getAuthenticationTokenParts(AuthenticationToken token) throws AuthenticationException { UsernamePasswordToken upToken = (UsernamePasswordToken) token; String username = upToken.getUsername(); String passwordWithToken = String.copyValueOf( (char[]) token.getCredentials()); Pattern tokenPattern = Pattern.compile(properties.get("tokenPattern")); Matcher matcher = tokenPattern.matcher(passwordWithToken); if (!matcher.matches()) { throw new AuthenticationException( "Invalid password format: Does not match match " + tokenPattern.pattern() ); } String password = matcher.group(1); String tokenPart = matcher.group(2); return new String[]{ username, password, tokenPart}; } default boolean validateToken(String username, String token) { try (CloseableHttpClient client = HttpClients.createDefault()) { HttpPost request = new HttpPost(properties.get("soapUrl")); request.setHeader("Content-Type", "text/xml; charset=utf-8"); request.setHeader("SOAPAction", "authenticateToken"); request.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString( (properties.get("soapUser") + ":" + properties.get("soapPass")) .getBytes(StandardCharsets.UTF_8) ) ); String soapRequest = buildSOAPRequest(properties.get("soapWs"), username, token); if (log.isTraceEnabled()) { log.trace("Sending Soap Token Request:\n" + soapRequest); } request.setEntity(new StringEntity(soapRequest)); try (CloseableHttpResponse response = client.execute(request)) { String xmlResponse = EntityUtils.toString(response.getEntity()); if (log.isTraceEnabled()) { log.trace("Got response:\n" + xmlResponse); } return parseIsSuccessful(xmlResponse); } } catch (Exception e) { log.error("Failed to validate the token", e); return false; } } default boolean parseIsSuccessful(String xmlResponse) { try { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilder builder = factory.newDocumentBuilder(); Document document = builder.parse(new ByteArrayInputStream(xmlResponse.getBytes(StandardCharsets.UTF_8))); NodeList nodeList = document.getElementsByTagName("isSuccessful"); if (nodeList.getLength() > 0) { return Boolean.parseBoolean(nodeList.item(0).getTextContent().trim()); } } catch (Exception e) { log.error("Failed to parse the response", e); } return false; } default String buildSOAPRequest(String soapWs, String username, String token) { return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + "<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\"\n" + " xmlns:ws=\"" + soapWs + "\">\n" + " <soapenv:Header/>\n" + " <soapenv:Body>\n" + " <ws:authenticateToken>\n" + " <request>\n" + " <response>" + token + "</response>\n" + " <userGroup></userGroup>\n" + " <username>" + username + "</username>\n" + " <requesterId>?</requesterId>\n" + " <requesterIp>?</requesterIp>\n" + " </request>\n" + " </ws:authenticateToken>\n" + " </soapenv:Body>\n" + "</soapenv:Envelope>"; } }
package com.manticore.common.auth; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.IncorrectCredentialsException; import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.authc.credential.AllowAllCredentialsMatcher; import org.apache.shiro.authc.credential.CredentialsMatcher; public class TokenAwareMJdbcRealm extends MJdbcRealm implements TokenAware { @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String[] authenticationTokenParts = getAuthenticationTokenParts(token); String username = authenticationTokenParts[0]; String password = authenticationTokenParts[1]; // Authenticate against the Realm return super.doGetAuthenticationInfo(new UsernamePasswordToken(username, password)); } @Override protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException { String[] authenticationParts = getAuthenticationTokenParts(token); String username = authenticationParts[0]; String password = authenticationParts[1]; String tokenPart = authenticationParts[2]; UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password); CredentialsMatcher cm = this.getCredentialsMatcher(); if (cm != null) { if (!cm.doCredentialsMatch(usernamePasswordToken, info)) { String msg = "Submitted credentials for token [" + usernamePasswordToken + "] did not match the expected credentials."; throw new IncorrectCredentialsException(msg); } // 2nd Factor Authorization if (!validateToken(username, tokenPart)) { throw new AuthenticationException("Token authentication failed."); } } else { throw new AuthenticationException("A CredentialsMatcher must be configured in order to verify credentials during authentication. If you do not wish for credentials to be examined, you can configure an " + AllowAllCredentialsMatcher.class.getName() + " instance."); } } }
package com.manticore.common.auth; import org.apache.shiro.authc.*; import org.apache.shiro.authc.credential.AllowAllCredentialsMatcher; import org.apache.shiro.authc.credential.CredentialsMatcher; import org.apache.shiro.realm.ldap.DefaultLdapRealm; public class TokenAwareLdapRealm extends DefaultLdapRealm implements TokenAware { @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String[] authenticationTokenParts = getAuthenticationTokenParts(token); String username = authenticationTokenParts[0]; String password = authenticationTokenParts[1]; // Authenticate against the Realm return super.doGetAuthenticationInfo(new UsernamePasswordToken(username, password)); } @Override protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException { String[] authenticationParts = getAuthenticationTokenParts(token); String username = authenticationParts[0]; String password = authenticationParts[1]; String tokenPart = authenticationParts[2]; UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password); CredentialsMatcher cm = this.getCredentialsMatcher(); if (cm != null) { if (!cm.doCredentialsMatch(usernamePasswordToken, info)) { String msg = "Submitted credentials for token [" + usernamePasswordToken + "] did not match the expected credentials."; throw new IncorrectCredentialsException(msg); } // 2nd Factor Authorization if (!validateToken(username, tokenPart)) { throw new AuthenticationException("Token authentication failed."); } } else { throw new AuthenticationException("A CredentialsMatcher must be configured in order to verify credentials during authentication. If you do not wish for credentials to be examined, you can configure an " + AllowAllCredentialsMatcher.class.getName() + " instance."); } } }
package com.manticore.common.auth; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.IncorrectCredentialsException; import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.authc.credential.AllowAllCredentialsMatcher; import org.apache.shiro.authc.credential.CredentialsMatcher; import org.apache.shiro.realm.activedirectory.ActiveDirectoryRealm; public class TokenAwareActiveDirectoryRealm extends ActiveDirectoryRealm implements TokenAware { @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String[] authenticationTokenParts = getAuthenticationTokenParts(token); String username = authenticationTokenParts[0]; String password = authenticationTokenParts[1]; // Authenticate against the Realm return super.doGetAuthenticationInfo(new UsernamePasswordToken(username, password)); } @Override protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException { String[] authenticationParts = getAuthenticationTokenParts(token); String username = authenticationParts[0]; String password = authenticationParts[1]; String tokenPart = authenticationParts[2]; UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password); CredentialsMatcher cm = this.getCredentialsMatcher(); if (cm != null) { if (!cm.doCredentialsMatch(usernamePasswordToken, info)) { String msg = "Submitted credentials for token [" + usernamePasswordToken + "] did not match the expected credentials."; throw new IncorrectCredentialsException(msg); } // 2nd Factor Authorization if (!validateToken(username, tokenPart)) { throw new AuthenticationException("Token authentication failed."); } } else { throw new AuthenticationException("A CredentialsMatcher must be configured in order to verify credentials during authentication. If you do not wish for credentials to be examined, you can configure an " + AllowAllCredentialsMatcher.class.getName() + " instance."); } } }