Преглед на файлове

SEC-588: PersistentTokenBasedRememberMeServices implementation.

Luke Taylor преди 18 години
родител
ревизия
55b1f9348d

+ 306 - 0
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 <tt>processAutoLoginCookie</tt> method.
+     * <p>
+     * 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 <tt>alwaysRemember</tt> is set to true, calls <tt>onLoginSucces</tt>.
+     */
+    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 <tt>alwaysRemember</tt> 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;
+    }
+}

+ 11 - 0
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);
+    }
+}

+ 44 - 0
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();
+            }
+        }
+    }
+}

+ 11 - 0
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);
+    }
+}

+ 37 - 0
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;
+    }
+}

+ 149 - 0
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 
+ * <a href="http://jaspan.com/improved_persistent_login_cookie_best_practice">Improved Persistent Login Cookie
+ * Best Practice</a>.
+ *
+ * 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.
+ *
+ * <p>User management such as changing passwords, removing users and setting user status should be combined
+ * with maintenance of the user's persistent tokens.
+ * </p>
+ *
+ * <p>Note that while this class will use the date a token was created to check whether a presented cookie
+ * is older than the configured <tt>tokenValiditySeconds</tt> 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.
+ * </p>
+ *
+ * @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;
+    }
+}

+ 15 - 0
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);
+
+}

+ 14 - 0
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);
+    }
+}

+ 1 - 2
core/src/main/java/org/springframework/security/ui/rememberme/TokenBasedRememberMeServices.java

@@ -95,8 +95,7 @@ import org.springframework.web.bind.RequestUtils;
  * </p>
  *
  * @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 =====================================================================================

+ 277 - 0
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;
+        }
+    }
+}

+ 99 - 0
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;
+        }
+    }
+}