Skip to content

Add SHA256 as an algorithm option for Remember Me token hashing #11464

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions docs/modules/ROOT/pages/servlet/authentication/rememberme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ In essence, a cookie is sent to the browser upon successful interactive authenti
====
[source,txt]
----
base64(username + ":" + expirationTime + ":" +
md5Hex(username + ":" + expirationTime + ":" password + ":" + key))
base64(username + ":" + expirationTime + ":" + algorithmName + ":"
algorithmHex(username + ":" + expirationTime + ":" password + ":" + key))

username: As identifiable to the UserDetailsService
password: That matches the one in the retrieved UserDetails
expirationTime: The date and time when the remember-me token expires, expressed in milliseconds
key: A private key to prevent modification of the remember-me token
algorithmName: The algorithm used to generate and to verify the remember-me token signature
----
====

Expand Down Expand Up @@ -113,6 +114,53 @@ A `key` is shared between this authentication provider and the `TokenBasedRememb
In addition, `TokenBasedRememberMeServices` requires a `UserDetailsService`, from which it can retrieve the username and password for signature comparison purposes and generate the `RememberMeAuthenticationToken` to contain the correct `GrantedAuthority` instances.
`TokenBasedRememberMeServices` also implements Spring Security's `LogoutHandler` interface so that it can be used with `LogoutFilter` to have the cookie cleared automatically.

By default, this implementation uses the MD5 algorithm to encode the token signature.
To verify the token signature, the algorithm retrieved from `algorithmName` is parsed and used.
If no `algorithmName` is present, the default matching algorithm will be used, which is MD5.
You can specify different algorithms for signature encoding and for signature matching, this allows users to safely upgrade to a different encoding algorithm while still able to verify old ones if there is no `algorithmName` present.
To do that you can specify your customized `TokenBasedRememberMeServices` as a Bean and use it in the configuration.

====
.Java
[source,java,role="primary"]
----
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, RememberMeServices rememberMeServices) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().authenticated()
)
.rememberMe((remember) -> remember
.rememberMeServices(rememberMeServices)
);
return http.build();
}

@Bean
RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
RememberMeTokenAlgorithm encodingAlgorithm = RememberMeTokenAlgorithm.SHA256;
TokenBasedRememberMeServices rememberMe = new TokenBasedRememberMeServices(myKey, userDetailsService, encodingAlgorithm);
rememberMe.setMatchingAlgorithm(RememberMeTokenAlgorithm.MD5);
return rememberMe;
}
----
.XML
[source,xml,role="secondary"]
----
<http>
<remember-me services-ref="rememberMeServices"/>
</http>

<bean id="rememberMeServices" class=
"org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices">
<property name="userDetailsService" ref="myUserDetailsService"/>
<property name="key" value="springRocks"/>
<property name="matchingAlgorithm" value="MD5"/>
<property name="encodingAlgorithm" value="SHA256"/>
</bean>
----
====

The following beans are required in an application context to enable remember-me services:

====
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,21 @@
* The cookie encoded by this implementation adopts the following form:
*
* <pre>
* username + &quot;:&quot; + expiryTime + &quot;:&quot;
* + Md5Hex(username + &quot;:&quot; + expiryTime + &quot;:&quot; + password + &quot;:&quot; + key)
* username + &quot;:&quot; + expiryTime + &quot;:&quot; + algorithmName + &quot;:&quot;
* + algorithmHex(username + &quot;:&quot; + expiryTime + &quot;:&quot; + password + &quot;:&quot; + key)
* </pre>
*
* <p>
* This implementation uses the algorithm configured in {@link #encodingAlgorithm} to
* encode the signature. It will try to use the algorithm retrieved from the
* {@code algorithmName} to validate the signature. However, if the {@code algorithmName}
* is not present in the cookie value, the algorithm configured in
* {@link #matchingAlgorithm} will be used to validate the signature. This allows users to
* safely upgrade to a different encoding algorithm while still able to verify old ones if
* there is no {@code algorithmName} present.
* </p>
*
* <p>
* 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
Expand All @@ -80,19 +90,43 @@
* not be stored when the browser is closed.
*
* @author Ben Alex
* @author Marcus Da Coregio
*/
public class TokenBasedRememberMeServices extends AbstractRememberMeServices {

private static final RememberMeTokenAlgorithm DEFAULT_MATCHING_ALGORITHM = RememberMeTokenAlgorithm.MD5;

private static final RememberMeTokenAlgorithm DEFAULT_ENCODING_ALGORITHM = RememberMeTokenAlgorithm.MD5;

private final RememberMeTokenAlgorithm encodingAlgorithm;

private RememberMeTokenAlgorithm matchingAlgorithm = DEFAULT_MATCHING_ALGORITHM;

public TokenBasedRememberMeServices(String key, UserDetailsService userDetailsService) {
this(key, userDetailsService, DEFAULT_ENCODING_ALGORITHM);
}

/**
* Construct the instance with the parameters provided
* @param key the signature key
* @param userDetailsService the {@link UserDetailsService}
* @param encodingAlgorithm the {@link RememberMeTokenAlgorithm} used to encode the
* signature
* @since 5.8
*/
public TokenBasedRememberMeServices(String key, UserDetailsService userDetailsService,
RememberMeTokenAlgorithm encodingAlgorithm) {
super(key, userDetailsService);
Assert.notNull(encodingAlgorithm, "encodingAlgorithm cannot be null");
this.encodingAlgorithm = encodingAlgorithm;
}

@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) {
if (cookieTokens.length != 3) {
if (!isValidCookieTokensLength(cookieTokens)) {
throw new InvalidCookieException(
"Cookie token did not contain 3" + " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
"Cookie token did not contain 3 or 4 tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
}
long tokenExpiryTime = getTokenExpiryTime(cookieTokens);
if (isTokenExpired(tokenExpiryTime)) {
Expand All @@ -110,15 +144,27 @@ protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletR
// 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 actualTokenSignature = cookieTokens[2];
RememberMeTokenAlgorithm actualAlgorithm = this.matchingAlgorithm;
// If the cookie value contains the algorithm, we use that algorithm to check the
// signature
if (cookieTokens.length == 4) {
actualTokenSignature = cookieTokens[3];
actualAlgorithm = RememberMeTokenAlgorithm.valueOf(cookieTokens[2]);
}
String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
userDetails.getPassword());
if (!equals(expectedTokenSignature, cookieTokens[2])) {
throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2]
+ "' but expected '" + expectedTokenSignature + "'");
userDetails.getPassword(), actualAlgorithm);
if (!equals(expectedTokenSignature, actualTokenSignature)) {
throw new InvalidCookieException("Cookie contained signature '" + actualTokenSignature + "' but expected '"
+ expectedTokenSignature + "'");
}
return userDetails;
}

private boolean isValidCookieTokensLength(String[] cookieTokens) {
return cookieTokens.length == 3 || cookieTokens.length == 4;
}

private long getTokenExpiryTime(String[] cookieTokens) {
try {
return new Long(cookieTokens[1]);
Expand All @@ -130,17 +176,33 @@ private long getTokenExpiryTime(String[] cookieTokens) {
}

/**
* Calculates the digital signature to be put in the cookie. Default value is MD5
* ("username:tokenExpiryTime:password:key")
* Calculates the digital signature to be put in the cookie. Default value is
* {@link #encodingAlgorithm} applied to ("username:tokenExpiryTime:password:key")
*/
protected String makeTokenSignature(long tokenExpiryTime, String username, String password) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
try {
MessageDigest digest = MessageDigest.getInstance("MD5");
MessageDigest digest = MessageDigest.getInstance(this.encodingAlgorithm.getDigestAlgorithm());
return new String(Hex.encode(digest.digest(data.getBytes())));
}
catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("No MD5 algorithm available!");
throw new IllegalStateException("No " + this.encodingAlgorithm.name() + " algorithm available!");
}
}

/**
* Calculates the digital signature to be put in the cookie.
* @since 5.8
*/
protected String makeTokenSignature(long tokenExpiryTime, String username, String password,
RememberMeTokenAlgorithm algorithm) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
try {
MessageDigest digest = MessageDigest.getInstance(algorithm.getDigestAlgorithm());
return new String(Hex.encode(digest.digest(data.getBytes())));
}
catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("No " + algorithm.name() + " algorithm available!");
}
}

Expand Down Expand Up @@ -172,15 +234,25 @@ public void onLoginSuccess(HttpServletRequest request, HttpServletResponse respo
long expiryTime = System.currentTimeMillis();
// SEC-949
expiryTime += 1000L * ((tokenLifetime < 0) ? TWO_WEEKS_S : tokenLifetime);
String signatureValue = makeTokenSignature(expiryTime, username, password);
setCookie(new String[] { username, Long.toString(expiryTime), signatureValue }, tokenLifetime, request,
response);
String signatureValue = makeTokenSignature(expiryTime, username, password, this.encodingAlgorithm);
setCookie(new String[] { username, Long.toString(expiryTime), this.encodingAlgorithm.name(), signatureValue },
tokenLifetime, request, response);
if (this.logger.isDebugEnabled()) {
this.logger.debug(
"Added remember-me cookie for user '" + username + "', expiry: '" + new Date(expiryTime) + "'");
}
}

/**
* Sets the algorithm to be used to match the token signature
* @param matchingAlgorithm the matching algorithm
* @since 5.8
*/
public void setMatchingAlgorithm(RememberMeTokenAlgorithm matchingAlgorithm) {
Assert.notNull(matchingAlgorithm, "matchingAlgorithm cannot be null");
this.matchingAlgorithm = matchingAlgorithm;
}

/**
* Calculates the validity period in seconds for a newly generated remember-me login.
* After this period (from the current time) the remember-me login will be considered
Expand All @@ -190,7 +262,7 @@ public void onLoginSuccess(HttpServletRequest request, HttpServletResponse respo
* <p>
* The returned value will be used to work out the expiry time of the token and will
* also be used to set the <tt>maxAge</tt> property of the cookie.
*
* <p>
* See SEC-485.
* @param request the request passed to onLoginSuccess
* @param authentication the successful authentication object.
Expand Down Expand Up @@ -234,4 +306,20 @@ private static byte[] bytesUtf8(String s) {
return (s != null) ? Utf8.encode(s) : null;
}

public enum RememberMeTokenAlgorithm {

MD5("MD5"), SHA256("SHA-256");

private final String digestAlgorithm;

RememberMeTokenAlgorithm(String digestAlgorithm) {
this.digestAlgorithm = digestAlgorithm;
}

public String getDigestAlgorithm() {
return this.digestAlgorithm;
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@

package org.springframework.security.test.web;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;

import org.springframework.security.crypto.codec.Hex;
import org.springframework.util.DigestUtils;

public final class CodecTestUtils {
Expand Down Expand Up @@ -52,4 +55,14 @@ public static String md5Hex(String data) {
return DigestUtils.md5DigestAsHex(data.getBytes());
}

public static String algorithmHex(String algorithmName, String data) {
try {
MessageDigest digest = MessageDigest.getInstance(algorithmName);
return new String(Hex.encode(digest.digest(data.getBytes())));
}
catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("No " + algorithmName + " algorithm available!");
}
}

}
Loading