From 55b1f9348d2887f969df67a8a36a4167f03b963e Mon Sep 17 00:00:00 2001 From: Luke Taylor Date: Sat, 3 Nov 2007 22:11:26 +0000 Subject: [PATCH] SEC-588: PersistentTokenBasedRememberMeServices implementation. --- .../AbstractRememberMeServices.java | 306 ++++++++++++++++++ .../ui/rememberme/CookieTheftException.java | 11 + .../InMemoryTokenRepositoryImpl.java | 44 +++ .../ui/rememberme/InvalidCookieException.java | 11 + .../rememberme/PersistentRememberMeToken.java | 37 +++ ...ersistentTokenBasedRememberMeServices.java | 149 +++++++++ .../rememberme/PersistentTokenRepository.java | 15 + .../RememberMeAuthenticationException.java | 14 + .../TokenBasedRememberMeServices.java | 3 +- .../AbstractRememberMeServicesTests.java | 277 ++++++++++++++++ ...tentTokenBasedRememberMeServicesTests.java | 99 ++++++ 11 files changed, 964 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/org/springframework/security/ui/rememberme/AbstractRememberMeServices.java create mode 100644 core/src/main/java/org/springframework/security/ui/rememberme/CookieTheftException.java create mode 100644 core/src/main/java/org/springframework/security/ui/rememberme/InMemoryTokenRepositoryImpl.java create mode 100644 core/src/main/java/org/springframework/security/ui/rememberme/InvalidCookieException.java create mode 100644 core/src/main/java/org/springframework/security/ui/rememberme/PersistentRememberMeToken.java create mode 100644 core/src/main/java/org/springframework/security/ui/rememberme/PersistentTokenBasedRememberMeServices.java create mode 100644 core/src/main/java/org/springframework/security/ui/rememberme/PersistentTokenRepository.java create mode 100644 core/src/main/java/org/springframework/security/ui/rememberme/RememberMeAuthenticationException.java create mode 100644 core/src/test/java/org/springframework/security/ui/rememberme/AbstractRememberMeServicesTests.java create mode 100644 core/src/test/java/org/springframework/security/ui/rememberme/PersistentTokenBasedRememberMeServicesTests.java diff --git a/core/src/main/java/org/springframework/security/ui/rememberme/AbstractRememberMeServices.java b/core/src/main/java/org/springframework/security/ui/rememberme/AbstractRememberMeServices.java new file mode 100644 index 0000000000..fdf29868f3 --- /dev/null +++ b/core/src/main/java/org/springframework/security/ui/rememberme/AbstractRememberMeServices.java @@ -0,0 +1,306 @@ +package org.springframework.security.ui.rememberme; + +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.security.Authentication; +import org.springframework.security.SpringSecurityMessageSource; +import org.springframework.security.providers.rememberme.RememberMeAuthenticationToken; +import org.springframework.security.ui.AuthenticationDetailsSource; +import org.springframework.security.ui.AuthenticationDetailsSourceImpl; +import org.springframework.security.userdetails.UserDetails; +import org.springframework.security.userdetails.UserDetailsService; +import org.springframework.security.userdetails.UsernameNotFoundException; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.ServletRequestUtils; +import org.springframework.context.support.MessageSourceAccessor; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Base class for RememberMeServices implementations. + * + * @author Luke Taylor + * @version $Id$ + */ +public abstract class AbstractRememberMeServices implements RememberMeServices { + + 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 parameter = DEFAULT_PARAMETER; + private boolean alwaysRemember; + private String key; + private long tokenValiditySeconds = 1209600; // 14 days + + /** + * Template implementation which locates the Spring Security cookie, decodes it into + * a delimited array of tokens and submits it to subclasses for processing + * via the processAutoLoginCookie method. + *

+ * The returned username is then used to load the UserDetails object for the user, which in turn + * is used to create a valid authentication token. + */ + public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) { + String rememberMeCookie = extractRememberMeCookie(request); + + if (rememberMeCookie == null) { + return null; + } + + logger.debug("Remember-me cookie detected"); + + UserDetails user = null; + + try { + String[] cookieTokens = decodeCookie(rememberMeCookie); + String username = processAutoLoginCookie(cookieTokens, request, response); + user = loadAndValidateUserDetails(username); + } catch (CookieTheftException cte) { + cancelCookie(request, response); + throw cte; + } catch (UsernameNotFoundException noUser) { + cancelCookie(request, response); + logger.debug("Remember-me login was valid but corresponding user not found.", noUser); + return null; + } catch (InvalidCookieException invalidCookie) { + cancelCookie(request, response); + logger.debug("Invalid remember-me cookie: " + invalidCookie.getMessage()); + return null; + } catch (RememberMeAuthenticationException e) { + cancelCookie(request, response); + logger.debug("autoLogin failed", e); + return null; + } + + logger.debug("Remember-me cookie accepted"); + + RememberMeAuthenticationToken auth = new RememberMeAuthenticationToken(key, user, user.getAuthorities()); + auth.setDetails(authenticationDetailsSource.buildDetails(request)); + + return auth; + } + + /** + * Locates the Spring Security remember me cookie in the request. + * + * @param request the submitted request which is to be authenticated + * @return the cookie value (if present), null otherwise. + */ + private String extractRememberMeCookie(HttpServletRequest request) { + 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())) { + return cookies[i].getValue(); + } + } + + return null; + } + + /** + * Decodes the cookie and splits it into a set of token strings using the ":" delimiter. + * + * @param cookieValue the value obtained from the submitted cookie + * @return the array of tokens. + * @throws InvalidCookieException if the cookie was not base64 encoded. + */ + protected String[] decodeCookie(String cookieValue) throws InvalidCookieException { + for (int j = 0; j < cookieValue.length() % 4; j++) { + cookieValue = cookieValue + "="; + } + + if (!Base64.isArrayByteBase64(cookieValue.getBytes())) { + throw new InvalidCookieException( "Cookie token was not Base64 encoded; value was '" + cookieValue + "'"); + } + + String cookieAsPlainText = new String(Base64.decodeBase64(cookieValue.getBytes())); + + return StringUtils.delimitedListToStringArray(cookieAsPlainText, DELIMITER); + } + + /** + * Inverse operation of decodeCookie. + * + * @param cookieTokens the tokens to be encoded. + * @return base64 encoding of the tokens concatenated with the ":" delimiter. + */ + protected String encodeCookie(String[] cookieTokens) { + StringBuffer sb = new StringBuffer(); + for(int i=0; i < cookieTokens.length; i++) { + sb.append(cookieTokens[i]); + + if (i < cookieTokens.length - 1) { + sb.append(DELIMITER); + } + } + + String value = sb.toString(); + + sb = new StringBuffer(new String(Base64.encodeBase64(value.getBytes()))); + + while (sb.charAt(sb.length() - 1) == '=') { + sb.deleteCharAt(sb.length() - 1); + } + + return sb.toString(); + } + + protected UserDetails loadAndValidateUserDetails(String username) 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) { + cancelCookie(request, response); + onLoginFail(request, response); + } + + protected void onLoginFail(HttpServletRequest request, HttpServletResponse response) {} + + /** + * Examines the incoming request and checks for the presence of the configured "remember me" parameter. + * If it's present, or if alwaysRemember is set to true, calls onLoginSucces. + */ + public final void loginSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication successfulAuthentication) { + + if (!rememberMeRequested(request, parameter)) { + return; + } + + onLoginSuccess(request, response, successfulAuthentication); + } + + /** + * Called from loginSuccess when a remember-me login has been requested. + * Typically implemented by subclasses to set a remember-me cookie and potentially store a record + * of it if the implementation requires this. + */ + protected abstract void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication successfulAuthentication); + + /** + * Allows customization of whether a remember-me login has been requested. + * The default is to return true if alwaysRemember is set or the configured parameter name has + * been included in the request and is set to the value "true". + * + * @param request the request which may include + * @param parameter the configured remember-me parameter name. + * + * @return true if the request includes information indicating that a persistent login has been + * requested. + */ + protected boolean rememberMeRequested(HttpServletRequest request, String parameter) { + if (alwaysRemember) { + return true; + } + + if (!ServletRequestUtils.getBooleanParameter(request, parameter, false)) { + if (logger.isDebugEnabled()) { + logger.debug("Did not send remember-me cookie (principal did not set parameter '" + parameter + "')"); + } + return false; + } + + return true; + } + + /** + * Called from autoLogin to process the submitted pesistent login cookie. Subclasses should + * validate the cookie and perform any additional management required. + * + * @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. + * @throws RememberMeAuthenticationException if the cookie is invalid or the login is invalid for some + * other reason. + */ + protected abstract String processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, + HttpServletResponse response) throws RememberMeAuthenticationException; + + protected void cancelCookie(HttpServletRequest request, HttpServletResponse response) { + logger.debug("Cancelling cookie"); + + response.addCookie(makeCancelCookie(request)); + } + + 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 value, HttpServletRequest request, long maxAge) { + Cookie cookie = new Cookie(cookieName, value); + cookie.setMaxAge(new Long(maxAge).intValue()); + cookie.setPath(StringUtils.hasLength(request.getContextPath()) ? request.getContextPath() : "/"); + + return cookie; + } + + public void setCookieName(String cookieName) { + this.cookieName = cookieName; + } + + public void setAlwaysRemember(boolean alwaysRemember) { + this.alwaysRemember = alwaysRemember; + } + + public void setParameter(String parameter) { + this.parameter = parameter; + } + + protected UserDetailsService getUserDetailsService() { + return userDetailsService; + } + + public void setUserDetailsService(UserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + } + + public void setKey(String key) { + this.key = key; + } + + public void setTokenValiditySeconds(long tokenValiditySeconds) { + this.tokenValiditySeconds = tokenValiditySeconds; + } + + public long getTokenValiditySeconds() { + return tokenValiditySeconds; + } + + public AuthenticationDetailsSource getAuthenticationDetailsSource() { + return authenticationDetailsSource; + } +} diff --git a/core/src/main/java/org/springframework/security/ui/rememberme/CookieTheftException.java b/core/src/main/java/org/springframework/security/ui/rememberme/CookieTheftException.java new file mode 100644 index 0000000000..643d896786 --- /dev/null +++ b/core/src/main/java/org/springframework/security/ui/rememberme/CookieTheftException.java @@ -0,0 +1,11 @@ +package org.springframework.security.ui.rememberme; + +/** + * @author Luke Taylor + * @version $Id$ + */ +public class CookieTheftException extends RememberMeAuthenticationException { + public CookieTheftException(String message) { + super(message); + } +} diff --git a/core/src/main/java/org/springframework/security/ui/rememberme/InMemoryTokenRepositoryImpl.java b/core/src/main/java/org/springframework/security/ui/rememberme/InMemoryTokenRepositoryImpl.java new file mode 100644 index 0000000000..b3c199f43d --- /dev/null +++ b/core/src/main/java/org/springframework/security/ui/rememberme/InMemoryTokenRepositoryImpl.java @@ -0,0 +1,44 @@ +package org.springframework.security.ui.rememberme; + +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * @author Luke Taylor + * @version $Id$ + */ +public class InMemoryTokenRepositoryImpl implements PersistentTokenRepository { + private Map seriesTokens = new HashMap(); + + public synchronized void saveToken(PersistentRememberMeToken token) { + PersistentRememberMeToken current = (PersistentRememberMeToken) seriesTokens.get(token.getSeries()); + + if (current != null && !token.getUsername().equals(current.getUsername())) { + throw new DataIntegrityViolationException("Series Id already exists with different username"); + } + + // Store it, overwriting the existing one. + seriesTokens.put(token.getSeries(), token); + } + + public synchronized PersistentRememberMeToken getTokenForSeries(String seriesId) { + return (PersistentRememberMeToken) seriesTokens.get(seriesId); + } + + public synchronized void removeAllTokens(String username) { + Iterator series = seriesTokens.keySet().iterator(); + + while (series.hasNext()) { + Object seriesId = series.next(); + + PersistentRememberMeToken token = (PersistentRememberMeToken) seriesTokens.get(seriesId); + + if (username.equals(token.getUsername())) { + series.remove(); + } + } + } +} diff --git a/core/src/main/java/org/springframework/security/ui/rememberme/InvalidCookieException.java b/core/src/main/java/org/springframework/security/ui/rememberme/InvalidCookieException.java new file mode 100644 index 0000000000..335001a61a --- /dev/null +++ b/core/src/main/java/org/springframework/security/ui/rememberme/InvalidCookieException.java @@ -0,0 +1,11 @@ +package org.springframework.security.ui.rememberme; + +/** + * @author Luke Taylor + * @version $Id$ + */ +public class InvalidCookieException extends RememberMeAuthenticationException { + public InvalidCookieException(String message) { + super(message); + } +} diff --git a/core/src/main/java/org/springframework/security/ui/rememberme/PersistentRememberMeToken.java b/core/src/main/java/org/springframework/security/ui/rememberme/PersistentRememberMeToken.java new file mode 100644 index 0000000000..dd538adced --- /dev/null +++ b/core/src/main/java/org/springframework/security/ui/rememberme/PersistentRememberMeToken.java @@ -0,0 +1,37 @@ +package org.springframework.security.ui.rememberme; + +import java.util.Date; + +/** + * @author Luke Taylor + * @version $Id$ + */ +public class PersistentRememberMeToken { + private String username; + private String series; + private String tokenValue; + private Date date; + + public PersistentRememberMeToken(String username, String series, String tokenValue, Date date) { + this.username = username; + this.series = series; + this.tokenValue = tokenValue; + this.date = date; + } + + public String getUsername() { + return username; + } + + public String getSeries() { + return series; + } + + public String getTokenValue() { + return tokenValue; + } + + public Date getDate() { + return date; + } +} diff --git a/core/src/main/java/org/springframework/security/ui/rememberme/PersistentTokenBasedRememberMeServices.java b/core/src/main/java/org/springframework/security/ui/rememberme/PersistentTokenBasedRememberMeServices.java new file mode 100644 index 0000000000..9bc89099a8 --- /dev/null +++ b/core/src/main/java/org/springframework/security/ui/rememberme/PersistentTokenBasedRememberMeServices.java @@ -0,0 +1,149 @@ +package org.springframework.security.ui.rememberme; + +import org.apache.commons.codec.binary.Base64; +import org.springframework.security.Authentication; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Date; + +/** + * {@link RememberMeServices} implementation based on Barry Jaspan's + * Improved Persistent Login Cookie + * Best Practice. + * + * There is a slight modification to the described approach, in that the username is not stored as part of the cookie + * but obtained from the persistent store via an implementation of {@link PersistentTokenRepository}. The latter + * should place a unique constraint on the series identifier, so that it is impossible for the same identifier to be + * allocated to two different users. + * + *

User management such as changing passwords, removing users and setting user status should be combined + * with maintenance of the user's persistent tokens. + *

+ * + *

Note that while this class will use the date a token was created to check whether a presented cookie + * is older than the configured tokenValiditySeconds property and deny authentication in this case, + * it will to delete such tokens from the storage. A suitable batch process should be run periodically to + * remove expired tokens from the database. + *

+ * + * @author Luke Taylor + * @version $Id$ + */ +public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices { + + private PersistentTokenRepository tokenRepository = new InMemoryTokenRepositoryImpl(); + private SecureRandom random; + + public static final int DEFAULT_SERIES_LENGTH = 16; + public static final int DEFAULT_TOKEN_LENGTH = 16; + + private int seriesLength = DEFAULT_SERIES_LENGTH; + private int tokenLength = DEFAULT_TOKEN_LENGTH; + + public PersistentTokenBasedRememberMeServices() throws Exception { + random = SecureRandom.getInstance("SHA1PRNG"); + } + + /** + * Locates the presented cookie data in the token repository, using the series id. + * If the data compares successfully with that in the persistent store, a new token is generated and stored with + * the same series. The corresponding cookie value is set on the response. + * + * @param cookieTokens the series and token values + * + * @throws RememberMeAuthenticationException if there is no stored token corresponding to the submitted cookie, or + * if the token in the persistent store has expired. + * @throws InvalidCookieException if the cookie doesn't have two tokens as expected. + * @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) { + + if (cookieTokens.length != 2) { + throw new InvalidCookieException("Cookie token did not contain " + 2 + + " tokens, but contained '" + Arrays.asList(cookieTokens) + "'"); + } + + final String presentedSeries = cookieTokens[0]; + final String presentedToken = cookieTokens[1]; + + PersistentRememberMeToken token = tokenRepository.getTokenForSeries(presentedSeries); + + if (token == null) { + // No series match, so we can't authenticate using this cookie + throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries); + } + + // We have a match for this user/series combination + if (!presentedToken.equals(token.getTokenValue())) { + // Token doesn't match series value. Delete all logins for this user and throw an exception to warn them. + tokenRepository.removeAllTokens(token.getUsername()); + + throw new CookieTheftException(messages.getMessage("PersistentTokenBasedRememberMeServices.cookieStolen", + "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack.")); + } + + if (token.getDate().getTime() + getTokenValiditySeconds()*1000 < System.currentTimeMillis()) { + throw new RememberMeAuthenticationException("Remember-me login has expired"); + } + + // Token also matches, so login is valid. create and save new token with the *same* series number. + PersistentRememberMeToken newToken = createNewToken(token.getUsername(), token.getSeries()); + + addCookie(newToken, request, response); + + return token.getUsername(); + } + + /** + * Creates a new persistent login token with a new series number, stores the data in the + * persistent token repository and adds the corresponding cookie to the response. + * + */ + protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { + PersistentRememberMeToken token = createNewToken(successfulAuthentication.getName(), null); + addCookie(token, request, response); + } + + private PersistentRememberMeToken createNewToken(String username, String series) { + logger.debug("Creating new persistent login token for user " + username); + + if (series == null) { + byte[] newSeries = new byte[seriesLength]; + random.nextBytes(newSeries); + series = new String(Base64.encodeBase64(newSeries)); + logger.debug("New series: " + series); + } + + byte[] token = new byte[tokenLength]; + random.nextBytes(token); + + PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, series, + new String(Base64.encodeBase64(token)), new Date()); + + tokenRepository.saveToken(persistentToken); + + return persistentToken; + } + + private void addCookie(PersistentRememberMeToken token, HttpServletRequest request, HttpServletResponse response) { + String cookieValue = encodeCookie(new String[] {token.getSeries(), token.getTokenValue()}); + long maxAge = System.currentTimeMillis() + getTokenValiditySeconds() * 1000; + response.addCookie(makeValidCookie(cookieValue, request, maxAge)); + } + + public void setTokenRepository(PersistentTokenRepository tokenRepository) { + this.tokenRepository = tokenRepository; + } + + public void setSeriesLength(int seriesLength) { + this.seriesLength = seriesLength; + } + + public void setTokenLength(int tokenLength) { + this.tokenLength = tokenLength; + } +} diff --git a/core/src/main/java/org/springframework/security/ui/rememberme/PersistentTokenRepository.java b/core/src/main/java/org/springframework/security/ui/rememberme/PersistentTokenRepository.java new file mode 100644 index 0000000000..0e21918411 --- /dev/null +++ b/core/src/main/java/org/springframework/security/ui/rememberme/PersistentTokenRepository.java @@ -0,0 +1,15 @@ +package org.springframework.security.ui.rememberme; + +/** + * @author Luke Taylor + * @version $Id$ + */ +public interface PersistentTokenRepository { + + void saveToken(PersistentRememberMeToken token); + + PersistentRememberMeToken getTokenForSeries(String seriesId); + + void removeAllTokens(String username); + +} diff --git a/core/src/main/java/org/springframework/security/ui/rememberme/RememberMeAuthenticationException.java b/core/src/main/java/org/springframework/security/ui/rememberme/RememberMeAuthenticationException.java new file mode 100644 index 0000000000..3a0b09286a --- /dev/null +++ b/core/src/main/java/org/springframework/security/ui/rememberme/RememberMeAuthenticationException.java @@ -0,0 +1,14 @@ +package org.springframework.security.ui.rememberme; + +import org.springframework.security.AuthenticationException; + +/** + * @author Luke Taylor + * @version $Id$ + */ +public class RememberMeAuthenticationException extends AuthenticationException { + + public RememberMeAuthenticationException(String message) { + super(message); + } +} diff --git a/core/src/main/java/org/springframework/security/ui/rememberme/TokenBasedRememberMeServices.java b/core/src/main/java/org/springframework/security/ui/rememberme/TokenBasedRememberMeServices.java index 2e6082d328..ca9ad8b5bf 100644 --- a/core/src/main/java/org/springframework/security/ui/rememberme/TokenBasedRememberMeServices.java +++ b/core/src/main/java/org/springframework/security/ui/rememberme/TokenBasedRememberMeServices.java @@ -95,8 +95,7 @@ import org.springframework.web.bind.RequestUtils; *

* * @author Ben Alex - * @version $Id: TokenBasedRememberMeServices.java 1871 2007-05-25 03:12:49Z - * benalex $ + * @version $Id$ */ public class TokenBasedRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler { //~ Static fields/initializers ===================================================================================== diff --git a/core/src/test/java/org/springframework/security/ui/rememberme/AbstractRememberMeServicesTests.java b/core/src/test/java/org/springframework/security/ui/rememberme/AbstractRememberMeServicesTests.java new file mode 100644 index 0000000000..07b2f70e15 --- /dev/null +++ b/core/src/test/java/org/springframework/security/ui/rememberme/AbstractRememberMeServicesTests.java @@ -0,0 +1,277 @@ +package org.springframework.security.ui.rememberme; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.Authentication; +import org.springframework.security.GrantedAuthority; +import org.springframework.security.GrantedAuthorityImpl; +import org.springframework.security.providers.UsernamePasswordAuthenticationToken; +import org.springframework.security.userdetails.User; +import org.springframework.security.userdetails.UserDetails; +import org.springframework.security.userdetails.UserDetailsService; +import org.springframework.security.userdetails.UsernameNotFoundException; +import org.springframework.util.StringUtils; + +import static org.junit.Assert.*; +import org.junit.Test; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * @author Luke Taylor + * @version $Id$ + */ +public class AbstractRememberMeServicesTests { + User joe = new User("joe", "password", true, true,true,true, new GrantedAuthority[] {new GrantedAuthorityImpl("ROLE_A")}); + + @Test(expected = InvalidCookieException.class) + public void nonBase64CookieShouldBeDetected() { + new MockRememberMeServices().decodeCookie("nonBase64CookieValue%"); + } + + @Test + public void cookieShouldBeCorrectlyEncodedAndDecoded() { + String[] cookie = new String[] {"the", "cookie", "tokens", "blah"}; + MockRememberMeServices services = new MockRememberMeServices(); + + String encoded = services.encodeCookie(cookie); + // '=' aren't alowed in version 0 cookies. + assertFalse(encoded.endsWith("=")); + String[] decoded = services.decodeCookie(encoded); + + assertEquals(4, decoded.length); + assertEquals("the", decoded[0]); + assertEquals("cookie", decoded[1]); + assertEquals("tokens", decoded[2]); + assertEquals("blah", decoded[3]); + } + + @Test + public void autoLoginShouldReturnNullIfNoLoginCookieIsPresented() { + MockRememberMeServices services = new MockRememberMeServices(); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertNull(services.autoLogin(request, response)); + + // shouldn't try to invalidate our cookie + assertNull(response.getCookie(AbstractRememberMeServices.SPRING_SECURITY_PERSISTENT_REMEMBER_ME_COOKIE_KEY)); + + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + // set non-login cookie + request.setCookies(new Cookie[] {new Cookie("mycookie", "cookie")}); + assertNull(services.autoLogin(request, response)); + assertNull(response.getCookie(AbstractRememberMeServices.SPRING_SECURITY_PERSISTENT_REMEMBER_ME_COOKIE_KEY)); + } + + @Test + public void successfulAutoLoginReturnsExpectedAuthentication() { + MockRememberMeServices services = new MockRememberMeServices(); + services.setUserDetailsService(new MockAuthenticationDao(joe, false)); + assertNotNull(services.getUserDetailsService()); + + MockHttpServletRequest request = new MockHttpServletRequest(); + + request.setCookies(createLoginCookie("cookie:1:2")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Authentication result = services.autoLogin(request, response); + + assertNotNull(result); + } + + @Test + public void autoLoginShouldFailIfInvalidCookieExceptionIsRaised() { + MockRememberMeServices services = new MockRememberMeServices(); + services.setUserDetailsService(new MockAuthenticationDao(joe, true)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + // Wrong number of tokes + request.setCookies(createLoginCookie("cookie:1")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Authentication result = services.autoLogin(request, response); + + assertNull(result); + + assertCookieCancelled(response); + } + + @Test + public void autoLoginShouldFailIfUserNotFound() { + MockRememberMeServices services = new MockRememberMeServices(); + services.setUserDetailsService(new MockAuthenticationDao(joe, true)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(createLoginCookie("cookie:1:2")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Authentication result = services.autoLogin(request, response); + + assertNull(result); + + assertCookieCancelled(response); + } + + @Test + public void autoLoginShouldFailIfUserAccountIsLocked() { + MockRememberMeServices services = new MockRememberMeServices(); + User joeLocked = new User("joe", "password",false,true,true,true,joe.getAuthorities()); + services.setUserDetailsService(new MockAuthenticationDao(joeLocked, false)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(createLoginCookie("cookie:1:2")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Authentication result = services.autoLogin(request, response); + + assertNull(result); + + assertCookieCancelled(response); + } + + @Test + public void loginFailShouldCancelCookie() { + MockRememberMeServices services = new MockRememberMeServices(); + services.setUserDetailsService(new MockAuthenticationDao(joe, true)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setContextPath("contextpath"); + request.setCookies(createLoginCookie("cookie:1:2")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + services.loginFail(request, response); + + assertCookieCancelled(response); + } + + @Test(expected = CookieTheftException.class) + public void cookieTheftExceptionShouldBeRethrown() { + MockRememberMeServices services = new MockRememberMeServices() { + protected String processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) { + throw new CookieTheftException("Pretending cookie was stolen"); + } + }; + + services.setUserDetailsService(new MockAuthenticationDao(joe, false)); + MockHttpServletRequest request = new MockHttpServletRequest(); + + request.setCookies(createLoginCookie("cookie:1:2")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + services.autoLogin(request, response); + } + + @Test + public void loginSuccessCallsOnLoginSuccessCorrectly() { + MockRememberMeServices services = new MockRememberMeServices(); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + Authentication auth = new UsernamePasswordAuthenticationToken("joe","password"); + + // No parameter set + services = new MockRememberMeServices(); + services.loginSuccess(request, response, auth); + assertFalse(services.loginSuccessCalled); + + // Parameter set to true + services = new MockRememberMeServices(); + request.setParameter(MockRememberMeServices.DEFAULT_PARAMETER, "true"); + services.loginSuccess(request, response, auth); + assertTrue(services.loginSuccessCalled); + + // Different parameter name, set to true + services = new MockRememberMeServices(); + services.setParameter("my_parameter"); + request.setParameter("my_parameter", "true"); + services.loginSuccess(request, response, auth); + assertTrue(services.loginSuccessCalled); + + + // Parameter set to false + services = new MockRememberMeServices(); + request.setParameter(MockRememberMeServices.DEFAULT_PARAMETER, "false"); + services.loginSuccess(request, response, auth); + assertFalse(services.loginSuccessCalled); + + // alwaysRemember set to true + services = new MockRememberMeServices(); + services.setAlwaysRemember(true); + services.loginSuccess(request, response, auth); + assertTrue(services.loginSuccessCalled); + + } + + @Test + public void makeValidCookieUsesCorrectNamePathAndValue() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setContextPath("contextpath"); + MockRememberMeServices services = new MockRememberMeServices(); + services.setCookieName("mycookiename"); + Cookie cookie = services.makeValidCookie("mycookie", request, 1000); + + assertTrue(cookie.getValue().equals("mycookie")); + assertTrue(cookie.getName().equals("mycookiename")); + assertTrue(cookie.getPath().equals("contextpath")); + + } + + + private Cookie[] createLoginCookie(String cookieToken) { + MockRememberMeServices services = new MockRememberMeServices(); + Cookie cookie = new Cookie(AbstractRememberMeServices.SPRING_SECURITY_PERSISTENT_REMEMBER_ME_COOKIE_KEY, + services.encodeCookie(StringUtils.delimitedListToStringArray(cookieToken, ":"))); + + return new Cookie[] {cookie}; + } + + private void assertCookieCancelled(MockHttpServletResponse response) { + Cookie returnedCookie = response.getCookie(AbstractRememberMeServices.SPRING_SECURITY_PERSISTENT_REMEMBER_ME_COOKIE_KEY); + assertNotNull(returnedCookie); + assertEquals(0, returnedCookie.getMaxAge()); + } + + //~ Inner Classes ================================================================================================== + + private class MockRememberMeServices extends AbstractRememberMeServices { + boolean loginSuccessCalled; + + private MockRememberMeServices() { + setKey("key"); + } + + protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { + loginSuccessCalled = true; + } + + protected String processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) throws RememberMeAuthenticationException { + if(cookieTokens.length != 3) { + throw new InvalidCookieException("deliberate exception"); + } + + return "joe"; + } + } + + private class MockAuthenticationDao implements UserDetailsService { + private UserDetails toReturn; + private boolean throwException; + + public MockAuthenticationDao(UserDetails toReturn, boolean throwException) { + this.toReturn = toReturn; + this.throwException = throwException; + } + + public UserDetails loadUserByUsername(String username) { + if (throwException) { + throw new UsernameNotFoundException("as requested by mock"); + } + + return toReturn; + } + } +} diff --git a/core/src/test/java/org/springframework/security/ui/rememberme/PersistentTokenBasedRememberMeServicesTests.java b/core/src/test/java/org/springframework/security/ui/rememberme/PersistentTokenBasedRememberMeServicesTests.java new file mode 100644 index 0000000000..68f3fd9b0c --- /dev/null +++ b/core/src/test/java/org/springframework/security/ui/rememberme/PersistentTokenBasedRememberMeServicesTests.java @@ -0,0 +1,99 @@ +package org.springframework.security.ui.rememberme; + +import static org.junit.Assert.assertEquals; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.providers.UsernamePasswordAuthenticationToken; + +import java.util.Date; + +/** + * @author Luke Taylor + * @version $Id$ + */ +public class PersistentTokenBasedRememberMeServicesTests { + private PersistentTokenBasedRememberMeServices services; + + @Before + public void setUpData() throws Exception { + services = new PersistentTokenBasedRememberMeServices(); + } + + @Test(expected = InvalidCookieException.class) + public void loginIsRejectedWithWrongNumberOfCookieTokens() { + services.setCookieName("mycookiename"); + services.processAutoLoginCookie(new String[] {"series", "token", "extra"}, new MockHttpServletRequest(), + new MockHttpServletResponse()); + } + + @Test(expected = RememberMeAuthenticationException.class) + public void loginIsRejectedWhenNoTokenMatchingSeriesIsFound() { + services.setCookieName("mycookiename"); + services.setTokenRepository(new MockTokenRepository(null)); + services.processAutoLoginCookie(new String[] {"series", "token"}, new MockHttpServletRequest(), + new MockHttpServletResponse()); + } + + @Test(expected = CookieTheftException.class) + public void cookieTheftIsDetectedWhenSeriesAndTokenDontMatch() { + services.setCookieName("mycookiename"); + PersistentRememberMeToken token = new PersistentRememberMeToken("joe", "series","wrongtoken", new Date()); + services.setTokenRepository(new MockTokenRepository(token)); + services.processAutoLoginCookie(new String[] {"series", "token"}, new MockHttpServletRequest(), + new MockHttpServletResponse()); + } + + @Test + public void successfulAutoLoginCreatesNewTokenAndCookieWithSameSeries() { + services.setCookieName("mycookiename"); + MockTokenRepository repo = + new MockTokenRepository(new PersistentRememberMeToken("joe", "series","token", new Date())); + services.setTokenRepository(repo); + // 12 => b64 length will be 16 + services.setTokenLength(12); + services.processAutoLoginCookie(new String[] {"series", "token"}, new MockHttpServletRequest(), + new MockHttpServletResponse()); + assertEquals("series",repo.getStoredToken().getSeries()); + assertEquals(16, repo.getStoredToken().getTokenValue().length()); + } + + @Test + public void loginSuccessCreatesNewTokenAndCookieWithNewSeries() { + services.setAlwaysRemember(true); + MockTokenRepository repo = new MockTokenRepository(null); + services.setTokenRepository(repo); + services.setTokenLength(12); + services.setSeriesLength(12); + services.loginSuccess(new MockHttpServletRequest(), + new MockHttpServletResponse(), new UsernamePasswordAuthenticationToken("joe","password")); + assertEquals(16, repo.getStoredToken().getSeries().length()); + assertEquals(16, repo.getStoredToken().getTokenValue().length()); + } + + + + private class MockTokenRepository implements PersistentTokenRepository { + private PersistentRememberMeToken storedToken; + + private MockTokenRepository(PersistentRememberMeToken token) { + storedToken = token; + } + + public void saveToken(PersistentRememberMeToken token) { + storedToken = token; + } + + public PersistentRememberMeToken getTokenForSeries(String seriesId) { + return storedToken; + } + + public void removeAllTokens(String username) { + } + + PersistentRememberMeToken getStoredToken() { + return storedToken; + } + } +}