/**
* Copyright (C) 2011 JTalks.org Team
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
package org.jtalks.jcommune.web.rememberme;
import com.google.common.annotations.VisibleForTesting;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.rememberme.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Implements our custom Remember Me service to replace the Spring default one. This implementation removes Remember Me
* token only for a single session and prevents sequent remember me authentication from single client..
* <p><b>Justification:</b> Spring's * {@link PersistentTokenBasedRememberMeServices} removes all the tokens from DB
* for a user whose session expired - even the sessions started on a different machine or device. Thus users were
* frustrated when their sessions expired on the machines where the Remember Me checkbox was checked.
* </p>
*/
public class ThrottlingRememberMeService extends PersistentTokenBasedRememberMeServices {
private final static String REMOVE_TOKEN_QUERY = "DELETE FROM persistent_logins WHERE series = ? AND token = ?";
// We should store a lot of tokens to prevent cache overflow
private static final int TOKEN_CACHE_MAX_SIZE = 100;
private final RememberMeCookieDecoder rememberMeCookieDecoder;
private final JdbcTemplate jdbcTemplate;
private final Map<String, CachedRememberMeTokenInfo> tokenCache = new ConcurrentHashMap<>();
private PersistentTokenRepository tokenRepository = new InMemoryTokenRepositoryImpl();
// 5 seconds should be enough for processing request and sending response to client
private int cachedTokenValidityTime = 5 * 1000;
/**
* @param rememberMeCookieDecoder needed for extracting rememberme cookies
* @param jdbcTemplate needed to execute the sql queries
* @throws Exception - see why {@link PersistentTokenBasedRememberMeServices} throws it
*/
public ThrottlingRememberMeService(RememberMeCookieDecoder rememberMeCookieDecoder, JdbcTemplate jdbcTemplate)
throws Exception {
super();
this.rememberMeCookieDecoder = rememberMeCookieDecoder;
this.jdbcTemplate = jdbcTemplate;
}
/**
* Causes a logout to be completed. The method must complete successfully.
* Removes client's token which is extracted from the HTTP request.
* {@inheritDoc}
*/
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
String cookie = rememberMeCookieDecoder.exctractRememberMeCookieValue(request);
if (cookie != null) {
String[] seriesAndToken = rememberMeCookieDecoder.extractSeriesAndToken(cookie);
if (logger.isDebugEnabled()) {
logger.debug("Logout of user " + (authentication == null ? "Unknown" : authentication.getName()));
}
cancelCookie(request, response);
jdbcTemplate.update(REMOVE_TOKEN_QUERY, seriesAndToken);
tokenCache.remove(seriesAndToken[0]);
validateTokenCache();
}
}
/**
* Solution for preventing "remember-me" bug. Some browsers sends preloading requests to server to speed-up
* page loading. It may cause error when response of preload request not returned to client and second request
* from client was send. This method implementation stores token in cache for <link>CACHED_TOKEN_VALIDITY_TIME</link>
* milliseconds and check token presence in cache before process authentication. If there is no equivalent token in
* cache authentication performs normally. If equivalent present in cache we should not update token in database.
* This approach can provide acceptable security level and prevent errors.
* {@inheritDoc}
* @see <a href="http://jira.jtalks.org/browse/JC-1743">JC-1743</a>
* @see <a href="https://developers.google.com/chrome/whitepapers/prerender?csw=1">Page preloading in Google Chrome</a>
*/
@Override
protected UserDetails 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);
}
UserDetails details = null;
if (isTokenCached(presentedSeries, presentedToken)) {
tokenCache.remove(presentedSeries);
details = getUserDetailsService().loadUserByUsername(token.getUsername());
rewriteCookie(token, request, response);
} else {
/* IMPORTANT: We should store token in cache before calling <code>loginWithSpringSecurity</code> method.
Because execution of this method can take a long time.
*/
cacheToken(token);
try {
details = loginWithSpringSecurity(cookieTokens, request, response);
//We should remove token from cache if cookie really was stolen or other authentication error occurred
} catch (RememberMeAuthenticationException ex) {
tokenCache.remove(token.getSeries());
throw ex;
}
}
validateTokenCache();
return details;
}
/**
* Calls PersistentTokenBasedRememberMeServices#processAutoLoginCookie method.
* Needed for possibility to test.
*/
@VisibleForTesting
UserDetails loginWithSpringSecurity(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
return super.processAutoLoginCookie(cookieTokens, request, response);
}
/**
* Sets valid cookie to response
* Needed for possibility to test.
*/
@VisibleForTesting
void rewriteCookie(PersistentRememberMeToken token, HttpServletRequest request, HttpServletResponse response) {
setCookie(new String[] {token.getSeries(), token.getTokenValue()}, getTokenValiditySeconds(), request, response);
}
@Override
public void setTokenRepository(PersistentTokenRepository tokenRepository) {
this.tokenRepository = tokenRepository;
super.setTokenRepository(tokenRepository);
}
/**
* Stores token in cache.
* @param token Token to be stored
* @see CachedRememberMeTokenInfo
*/
private void cacheToken(PersistentRememberMeToken token) {
if (tokenCache.size() >= TOKEN_CACHE_MAX_SIZE) {
validateTokenCache();
}
CachedRememberMeTokenInfo tokenWrapper = new CachedRememberMeTokenInfo(token.getTokenValue(), System.currentTimeMillis());
tokenCache.put(token.getSeries(), tokenWrapper);
}
/**
* Removes from cache tokens which were stored more than <link>CACHED_TOKEN_VALIDITY_TIME</link> milliseconds ago.
*/
private void validateTokenCache() {
for (Map.Entry<String, CachedRememberMeTokenInfo> entry: tokenCache.entrySet()) {
if (!isTokenInfoValid(entry.getValue())) {
tokenCache.remove(entry.getKey());
}
}
}
/**
* Checks if given tokenInfo valid.
* @param tokenInfo Token wrapper to be checked
* @return <code>true</code> tokenInfo was stored in cache less than <link>CACHED_TOKEN_VALIDITY_TIME</link> milliseconds ago.
* <code>false</code> otherwise.
* @see CachedRememberMeTokenInfo
*/
private boolean isTokenInfoValid(CachedRememberMeTokenInfo tokenInfo) {
if ((System.currentTimeMillis() - tokenInfo.getCachingTime()) >= cachedTokenValidityTime) {
return false;
} else {
return true;
}
}
/**
* Checks if token with given series and value stored in cache
* @param series series to be checked
* @param value value to be checked
* @return <code>true</code> if token stored in cache< <code>false</code> otherwise.
*/
private boolean isTokenCached(String series, String value) {
if (tokenCache.containsKey(series) && isTokenInfoValid(tokenCache.get(series))
&& value.equals(tokenCache.get(series).getValue())) {
return true;
}
return false;
}
/**
* Needed for possibility to test.
*/
public void setCachedTokenValidityTime(int cachedTokenValidityTime) {
this.cachedTokenValidityTime = cachedTokenValidityTime;
}
}