SEC-565: Refactoring of TokenBasedRememberMeServices. Changed arguments to makeValidSignature so that it could be used from both places where a signature is required and refactored the class to extend AbstractRememberMeServices. The method processAutoLoginCookie now returns a UserDetails, rather than username, as the UserDetails is needed in TokenBasedRememberMeServices.

This commit is contained in:
Luke Taylor
2007-11-10 19:20:36 +00:00
parent 1a5ef2dece
commit 0e7dac6ca5
7 changed files with 161 additions and 396 deletions

View File

@@ -29,19 +29,23 @@ import javax.servlet.http.HttpServletResponse;
* @version $Id$
*/
public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler {
//~ Static fields/initializers =====================================================================================
public static final String SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY = "SPRING_SECURITY_REMEMBER_ME_COOKIE";
public static final String DEFAULT_PARAMETER = "_spring_security_remember_me";
private static final String DELIMITER = ":";
//~ Instance fields ================================================================================================
protected final Log logger = LogFactory.getLog(getClass());
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
public static final String DEFAULT_PARAMETER = "_spring_security_remember_me";
public static final String SPRING_SECURITY_PERSISTENT_REMEMBER_ME_COOKIE_KEY = "SPRING_SECURITY_REMEMBER_ME_COOKIE";
private static final String DELIMITER = ":";
private UserDetailsService userDetailsService;
private AuthenticationDetailsSource authenticationDetailsSource = new AuthenticationDetailsSourceImpl();
private String cookieName = SPRING_SECURITY_PERSISTENT_REMEMBER_ME_COOKIE_KEY;
private String cookieName = SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY;
private String parameter = DEFAULT_PARAMETER;
private boolean alwaysRemember;
private String key;
@@ -75,8 +79,7 @@ public abstract class AbstractRememberMeServices implements RememberMeServices,
try {
String[] cookieTokens = decodeCookie(rememberMeCookie);
String username = processAutoLoginCookie(cookieTokens, request, response);
user = loadAndValidateUserDetails(username);
user = processAutoLoginCookie(cookieTokens, request, response);
} catch (CookieTheftException cte) {
cancelCookie(request, response);
throw cte;
@@ -172,22 +175,23 @@ public abstract class AbstractRememberMeServices implements RememberMeServices,
return sb.toString();
}
protected UserDetails loadAndValidateUserDetails(String username) throws UsernameNotFoundException,
/**
* Provided for subclass convenience to check the account status of a loaded user.
*
* @throws UsernameNotFoundException if the username could not be located by the configured UserDetailsService.
* @throws RememberMeAuthenticationException if the account is locked or disabled.
*/
protected void validateUserDetails(UserDetails user) throws UsernameNotFoundException,
RememberMeAuthenticationException {
UserDetails user;
user = this.userDetailsService.loadUserByUsername(username);
if (!user.isAccountNonExpired() || !user.isCredentialsNonExpired() || !user.isEnabled()) {
throw new RememberMeAuthenticationException("Remember-me login was valid for user " +
user.getUsername() + ", but account is expired, has expired credentials or is disabled");
}
return user;
}
public final void loginFail(HttpServletRequest request, HttpServletResponse response) {
logger.debug("Interactive login attempt was unsuccessful.");
cancelCookie(request, response);
onLoginFail(request, response);
}
@@ -202,6 +206,7 @@ public abstract class AbstractRememberMeServices implements RememberMeServices,
Authentication successfulAuthentication) {
if (!rememberMeRequested(request, parameter)) {
logger.debug("Remember-me login not requested.");
return;
}
@@ -250,12 +255,14 @@ public abstract class AbstractRememberMeServices implements RememberMeServices,
* @param cookieTokens the decoded and tokenized cookie value
* @param request the request
* @param response the response, to allow the cookie to be modified if required.
* @return the name of the corresponding user account if the cookie was validated successfully.
* @return the UserDetails for the corresponding user account if the cookie was validated successfully.
* @throws RememberMeAuthenticationException if the cookie is invalid or the login is invalid for some
* other reason.
* @throws UsernameNotFoundException if the user account corresponding to the login cookie couldn't be found
* (for example if the user has been removed from the system).
*/
protected abstract String processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) throws RememberMeAuthenticationException;
protected abstract UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) throws RememberMeAuthenticationException, UsernameNotFoundException;
protected void cancelCookie(HttpServletRequest request, HttpServletResponse response) {
logger.debug("Cancelling cookie");
@@ -315,6 +322,10 @@ public abstract class AbstractRememberMeServices implements RememberMeServices,
this.key = key;
}
public String getKey() {
return key;
}
public void setTokenValiditySeconds(int tokenValiditySeconds) {
this.tokenValiditySeconds = tokenValiditySeconds;
}

View File

@@ -1,6 +1,9 @@
package org.springframework.security.ui.rememberme;
/**
* Exception thrown by a RememberMeServices implementation to indicate
* that a submitted cookie is of an invalid format or has expired.
*
* @author Luke Taylor
* @version $Id$
*/

View File

@@ -1,8 +1,10 @@
package org.springframework.security.ui.rememberme;
import org.apache.commons.codec.binary.Base64;
import org.springframework.dao.DataAccessException;
import org.springframework.security.Authentication;
import org.springframework.security.userdetails.UserDetails;
import org.springframework.dao.DataAccessException;
import org.apache.commons.codec.binary.Base64;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@@ -61,7 +63,7 @@ public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeSe
* @throws CookieTheftException if a presented series value is found, but the stored token is different from the
* one presented.
*/
protected String processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
if (cookieTokens.length != 2) {
throw new InvalidCookieException("Cookie token did not contain " + 2 +
@@ -108,7 +110,11 @@ public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeSe
throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
}
return token.getUsername();
UserDetails user = getUserDetailsService().loadUserByUsername(token.getUsername());
validateUserDetails(user);
return user;
}
/**

View File

@@ -15,28 +15,16 @@
package org.springframework.security.ui.rememberme;
import java.util.Date;
import org.springframework.security.Authentication;
import org.springframework.security.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import org.apache.commons.codec.digest.DigestUtils;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.Authentication;
import org.springframework.security.providers.rememberme.RememberMeAuthenticationToken;
import org.springframework.security.ui.AuthenticationDetailsSource;
import org.springframework.security.ui.AuthenticationDetailsSourceImpl;
import org.springframework.security.ui.logout.LogoutHandler;
import org.springframework.security.userdetails.UserDetails;
import org.springframework.security.userdetails.UserDetailsService;
import org.springframework.security.userdetails.UsernameNotFoundException;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.RequestUtils;
import java.util.Arrays;
import java.util.Date;
/**
* Identifies previously remembered users by a Base-64 encoded cookie.
@@ -44,8 +32,7 @@ import org.springframework.web.bind.RequestUtils;
* <p>
* This implementation does not rely on an external database, so is attractive
* for simple applications. The cookie will be valid for a specific period from
* the date of the last
* {@link #loginSuccess(HttpServletRequest, HttpServletResponse, Authentication)}.
* the date of the last {@link #loginSuccess(HttpServletRequest, HttpServletResponse, Authentication)}.
* As per the interface contract, this method will only be called when the
* principal completes a successful interactive authentication. As such the time
* period commences from the last authentication attempt where they furnished
@@ -72,7 +59,7 @@ import org.springframework.web.bind.RequestUtils;
*
* </p>
* <p>
* As such, if the user changes their password any remember-me token will be
* As such, if the user changes their password, any remember-me token will be
* invalidated. Equally, the system administrator may invalidate every
* remember-me token on issue by changing the key. This provides some reasonable
* approaches to recovering from a remember-me token being left on a public
@@ -86,366 +73,123 @@ import org.springframework.web.bind.RequestUtils;
* <p>
* This is a basic remember-me implementation which is suitable for many
* applications. However, we recommend a database-based implementation if you
* require a more secure remember-me approach.
* require a more secure remember-me approach (see {@link PersistentTokenBasedRememberMeServices}).
* </p>
* <p>
* By default the tokens will be valid for 14 days from the last successful
* authentication attempt. This can be changed using
* {@link #setTokenValiditySeconds(long)}.
* {@link #setTokenValiditySeconds(int)}.
* </p>
*
* @author Ben Alex
* @version $Id$
*/
public class TokenBasedRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler {
//~ Static fields/initializers =====================================================================================
public static final String SPRING_SECURITY_HASHED_REMEMBER_ME_COOKIE_KEY = "SPRING_SECURITY_HASHED_REMEMBER_ME_COOKIE";
public static final String DEFAULT_PARAMETER = "_spring_security_remember_me";
protected static final Log logger = LogFactory.getLog(TokenBasedRememberMeServices.class);
//~ Instance fields ================================================================================================
protected AuthenticationDetailsSource authenticationDetailsSource = new AuthenticationDetailsSourceImpl();
private String key;
private String parameter = DEFAULT_PARAMETER;
private UserDetailsService userDetailsService;
protected long tokenValiditySeconds = 1209600; // 14 days
private boolean alwaysRemember = false;
private String cookieName = SPRING_SECURITY_HASHED_REMEMBER_ME_COOKIE_KEY;
public class TokenBasedRememberMeServices extends AbstractRememberMeServices {
//~ Methods ========================================================================================================
public void afterPropertiesSet() throws Exception {
Assert.hasLength(key);
Assert.hasLength(parameter);
Assert.hasLength(cookieName);
Assert.notNull(userDetailsService);
}
public UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) {
public Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
Cookie[] cookies = request.getCookies();
if ((cookies == null) || (cookies.length == 0)) {
return null;
}
for (int i = 0; i < cookies.length; i++) {
if (!cookieName.equals(cookies[i].getName())) {
continue;
}
// We have the spring security cookie
String cookieValue = cookies[i].getValue();
if (logger.isDebugEnabled()) {
logger.debug("Remember-me cookie detected");
}
for (int j = 0; j < cookieValue.length() % 4; j++) {
cookieValue = cookieValue + "=";
}
if (!Base64.isArrayByteBase64(cookieValue.getBytes())) {
cancelCookie(request, response, "Cookie token was not Base64 encoded; value was '" + cookieValue + "'");
return null;
}
// Decode token from Base64
// format of token is:
// username + ":" + expiryTime + ":" + Md5Hex(username + ":" + expiryTime + ":" + password + ":" + key)
String cookieAsPlainText = new String(Base64.decodeBase64(cookieValue.getBytes()));
String[] cookieTokens = StringUtils.delimitedListToStringArray(cookieAsPlainText, ":");
if (cookieTokens.length != 3) {
cancelCookie(request, response, "Cookie token did not contain 3 tokens; decoded value was '"
+ cookieAsPlainText + "'");
return null;
}
long tokenExpiryTime;
try {
tokenExpiryTime = new Long(cookieTokens[1]).longValue();
}
catch (NumberFormatException nfe) {
cancelCookie(request, response,
"Cookie token[1] did not contain a valid number (contained '" + cookieTokens[1] + "')");
return null;
}
if (isTokenExpired(tokenExpiryTime)) {
cancelCookie(request, response, "Cookie token[1] has expired (expired on '"
+ new Date(tokenExpiryTime) + "'; current time is '" + new Date() + "')");
return null;
}
// Check the user exists
// Defer lookup until after expiry time checked, to
// possibly avoid expensive lookup
UserDetails userDetails = loadUserDetails(request, response, cookieTokens);
if (userDetails == null) {
cancelCookie(request, response, "Cookie token[0] contained username '" + cookieTokens[0]
+ "' but was not found");
return null;
}
if (!isValidUserDetails(request, response, userDetails, cookieTokens)) {
return null;
}
// Check signature of token matches remaining details
// Must do this after user lookup, as we need the
// DAO-derived password
// If efficiency was a major issue, just add in a
// UserCache implementation,
// but recall this method is usually only called one per
// HttpSession
// (as if the token is valid, it will cause
// SecurityContextHolder population, whilst
// if invalid, will cause the cookie to be cancelled)
String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails);
if (!expectedTokenSignature.equals(cookieTokens[2])) {
cancelCookie(request, response, "Cookie token[2] contained signature '" + cookieTokens[2]
+ "' but expected '" + expectedTokenSignature + "'");
return null;
}
// By this stage we have a valid token
if (logger.isDebugEnabled()) {
logger.debug("Remember-me cookie accepted");
}
RememberMeAuthenticationToken auth = new RememberMeAuthenticationToken(this.key, userDetails,
userDetails.getAuthorities());
auth.setDetails(authenticationDetailsSource.buildDetails((HttpServletRequest) request));
return auth;
if (cookieTokens.length != 3) {
throw new InvalidCookieException("Cookie token did not contain " + 2 +
" tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
}
long tokenExpiryTime;
return null;
}
try {
tokenExpiryTime = new Long(cookieTokens[1]).longValue();
}
catch (NumberFormatException nfe) {
throw new InvalidCookieException("Cookie token[1] did not contain a valid number (contained '" +
cookieTokens[1] + "')");
}
/**
* @param tokenExpiryTime
* @param userDetails
* @return
*/
protected String makeTokenSignature(long tokenExpiryTime, UserDetails userDetails) {
String expectedTokenSignature = DigestUtils.md5Hex(userDetails.getUsername() + ":" + tokenExpiryTime + ":"
+ userDetails.getPassword() + ":" + this.key);
return expectedTokenSignature;
}
if (isTokenExpired(tokenExpiryTime)) {
throw new InvalidCookieException("Cookie token[1] has expired (expired on '"
+ new Date(tokenExpiryTime) + "'; current time is '" + new Date() + "')");
}
protected boolean isValidUserDetails(HttpServletRequest request, HttpServletResponse response,
UserDetails userDetails, String[] cookieTokens) {
// Immediately reject if the user is not allowed to
// login
if (!userDetails.isAccountNonExpired() || !userDetails.isCredentialsNonExpired() || !userDetails.isEnabled()) {
cancelCookie(request, response, "Cookie token[0] contained username '" + cookieTokens[0]
+ "' but account has expired, credentials have expired, or user is disabled");
// Check the user exists.
// Defer lookup until after expiry time checked, to possibly avoid expensive database call.
return false;
}
return true;
}
UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);
protected UserDetails loadUserDetails(HttpServletRequest request, HttpServletResponse response,
String[] cookieTokens) {
UserDetails userDetails = null;
validateUserDetails(userDetails);
try {
userDetails = this.userDetailsService.loadUserByUsername(cookieTokens[0]);
}
catch (UsernameNotFoundException notFound) {
cancelCookie(request, response, "Cookie token[0] contained username '" + cookieTokens[0]
+ "' but was not found");
// Check signature of token matches remaining details.
// Must do this after user lookup, as we need the DAO-derived password.
// If efficiency was a major issue, just add in a UserCache implementation,
// but recall that this method is usually only called once per HttpSession - if the token is valid,
// it will cause SecurityContextHolder population, whilst if invalid, will cause the cookie to be cancelled.
String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
userDetails.getPassword());
return null;
}
return userDetails;
if (!expectedTokenSignature.equals(cookieTokens[2])) {
throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2]
+ "' but expected '" + expectedTokenSignature + "'");
}
return userDetails;
}
/**
* Calculates the digital signature to be put in the cookie. Default value is
* MD5 ("username:tokenExpiryTime:password:key")
*/
protected String makeTokenSignature(long tokenExpiryTime, String username, String password) {
return DigestUtils.md5Hex(username + ":" + tokenExpiryTime + ":" + password + ":" + getKey());
}
protected boolean isTokenExpired(long tokenExpiryTime) {
// Check it has not expired
if (tokenExpiryTime < System.currentTimeMillis()) {
return true;
}
return false;
return tokenExpiryTime < System.currentTimeMillis();
}
protected void cancelCookie(HttpServletRequest request, HttpServletResponse response, String reasonForLog) {
if ((reasonForLog != null) && logger.isDebugEnabled()) {
logger.debug("Cancelling cookie for reason: " + reasonForLog);
}
response.addCookie(makeCancelCookie(request));
}
public String getKey() {
return key;
}
public String getParameter() {
return parameter;
}
public long getTokenValiditySeconds() {
return tokenValiditySeconds;
}
public UserDetailsService getUserDetailsService() {
return userDetailsService;
}
public void loginFail(HttpServletRequest request, HttpServletResponse response) {
cancelCookie(request, response, "Interactive authentication attempt was unsuccessful");
}
protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
if (alwaysRemember) {
return true;
}
return RequestUtils.getBooleanParameter(request, parameter, false);
}
public void loginSuccess(HttpServletRequest request, HttpServletResponse response,
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
// Exit if the principal hasn't asked to be remembered
if (!rememberMeRequested(request, parameter)) {
if (logger.isDebugEnabled()) {
logger.debug("Did not send remember-me cookie (principal did not set parameter '" + this.parameter
+ "')");
}
return;
}
// Determine username and password, ensuring empty strings
Assert.notNull(successfulAuthentication.getPrincipal());
Assert.notNull(successfulAuthentication.getCredentials());
String username = retrieveUserName(successfulAuthentication);
String password = retrievePassword(successfulAuthentication);
// If unable to find a username and password, just abort as
// TokenBasedRememberMeServices unable to construct a valid token in
// this case
// If unable to find a username and password, just abort as TokenBasedRememberMeServices is
// unable to construct a valid token in this case.
if (!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) {
return;
}
long expiryTime = System.currentTimeMillis() + (tokenValiditySeconds * 1000);
long expiryTime = System.currentTimeMillis() + getTokenValiditySeconds() * 1000;
// construct token to put in cookie; format is:
// username + ":" + expiryTime + ":" + Md5Hex(username + ":" +
// expiryTime + ":" + password + ":" + key)
String signatureValue = DigestUtils.md5Hex(username + ":" + expiryTime + ":" + password + ":" + key);
String tokenValue = username + ":" + expiryTime + ":" + signatureValue;
String tokenValueBase64 = new String(Base64.encodeBase64(tokenValue.getBytes()));
response.addCookie(makeValidCookie(tokenValueBase64, request, tokenValiditySeconds));
String signatureValue = makeTokenSignature(expiryTime, username, password);
String cookieValue = encodeCookie(new String[] {username, Long.toString(expiryTime), signatureValue});
response.addCookie(makeValidCookie(cookieValue, request, getTokenValiditySeconds()));
if (logger.isDebugEnabled()) {
logger.debug("Added remember-me cookie for user '"
+ username + "', expiry: '" + new Date(expiryTime) + "'");
logger.debug("Added remember-me cookie for user '" + username + "', expiry: '"
+ new Date(expiryTime) + "'");
}
}
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
cancelCookie(request, response, "Logout of user "
+ (authentication == null ? "Unknown" : authentication.getName()));
}
protected String retrieveUserName(Authentication successfulAuthentication) {
if (isInstanceOfUserDetails(successfulAuthentication)) {
return ((UserDetails) successfulAuthentication.getPrincipal()).getUsername();
protected String retrieveUserName(Authentication authentication) {
if (isInstanceOfUserDetails(authentication)) {
return ((UserDetails) authentication.getPrincipal()).getUsername();
}
else {
return successfulAuthentication.getPrincipal().toString();
return authentication.getPrincipal().toString();
}
}
protected String retrievePassword(Authentication successfulAuthentication) {
if (isInstanceOfUserDetails(successfulAuthentication)) {
return ((UserDetails) successfulAuthentication.getPrincipal()).getPassword();
protected String retrievePassword(Authentication authentication) {
if (isInstanceOfUserDetails(authentication)) {
return ((UserDetails) authentication.getPrincipal()).getPassword();
}
else {
return successfulAuthentication.getCredentials().toString();
return authentication.getCredentials().toString();
}
}
private boolean isInstanceOfUserDetails(Authentication authentication) {
return authentication.getPrincipal() instanceof UserDetails;
}
protected Cookie makeCancelCookie(HttpServletRequest request) {
Cookie cookie = new Cookie(cookieName, null);
cookie.setMaxAge(0);
cookie.setPath(StringUtils.hasLength(request.getContextPath()) ? request.getContextPath() : "/");
return cookie;
}
protected Cookie makeValidCookie(String tokenValueBase64, HttpServletRequest request, long maxAge) {
Cookie cookie = new Cookie(cookieName, tokenValueBase64);
cookie.setMaxAge(new Long(maxAge).intValue());
cookie.setPath(StringUtils.hasLength(request.getContextPath()) ? request.getContextPath() : "/");
return cookie;
}
public void setAuthenticationDetailsSource(AuthenticationDetailsSource authenticationDetailsSource) {
Assert.notNull(authenticationDetailsSource, "AuthenticationDetailsSource required");
this.authenticationDetailsSource = authenticationDetailsSource;
}
public void setKey(String key) {
this.key = key;
}
public void setParameter(String parameter) {
this.parameter = parameter;
}
public void setCookieName(String cookieName) {
this.cookieName = cookieName;
}
public void setTokenValiditySeconds(long tokenValiditySeconds) {
this.tokenValiditySeconds = tokenValiditySeconds;
}
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
public boolean isAlwaysRemember() {
return alwaysRemember;
}
public void setAlwaysRemember(boolean alwaysRemember) {
this.alwaysRemember = alwaysRemember;
}
public String getCookieName() {
return cookieName;
}
}