Skip to content

Commit f45c4d4

Browse files
Add SHA256 as an algorithm option for Remember Me token hashing
Closes gh-8549
1 parent 5dff157 commit f45c4d4

File tree

4 files changed

+344
-35
lines changed

4 files changed

+344
-35
lines changed

docs/modules/ROOT/pages/servlet/authentication/rememberme.adoc

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,19 @@ If you are using an authentication provider which doesn't use a `UserDetailsServ
1818
This approach uses hashing to achieve a useful remember-me strategy.
1919
In essence a cookie is sent to the browser upon successful interactive authentication, with the cookie being composed as follows:
2020

21+
====
2122
[source,txt]
2223
----
23-
base64(username + ":" + expirationTime + ":" +
24-
md5Hex(username + ":" + expirationTime + ":" password + ":" + key))
24+
base64(username + ":" + expirationTime + ":" + algorithmName + ":"
25+
algorithmHex(username + ":" + expirationTime + ":" password + ":" + key))
2526
2627
username: As identifiable to the UserDetailsService
2728
password: That matches the one in the retrieved UserDetails
2829
expirationTime: The date and time when the remember-me token expires, expressed in milliseconds
2930
key: A private key to prevent modification of the remember-me token
31+
algorithmName: The algorithm used to generate and to verify the remember-me token signature
3032
----
33+
====
3134

3235
As such the remember-me token is valid only for the period specified, and provided that the username, password and key does not change.
3336
Notably, this has a potential security issue in that a captured remember-me token will be usable from any user agent until such time as the token expires.
@@ -38,13 +41,15 @@ Alternatively, remember-me services should simply not be used at all.
3841

3942
If you are familiar with the topics discussed in the chapter on xref:servlet/configuration/xml-namespace.adoc#ns-config[namespace configuration], you can enable remember-me authentication just by adding the `<remember-me>` element:
4043

44+
====
4145
[source,xml]
4246
----
4347
<http>
4448
...
4549
<remember-me key="myAppKey"/>
4650
</http>
4751
----
52+
====
4853

4954
The `UserDetailsService` will normally be selected automatically.
5055
If you have more than one in your application context, you need to specify which one should be used with the `user-service-ref` attribute, where the value is the name of your `UserDetailsService` bean.
@@ -55,23 +60,27 @@ This approach is based on the article https://web.archive.org/web/20180819014446
5560
There is a discussion on this in the comments section of this article.].
5661
To use the this approach with namespace configuration, you would supply a datasource reference:
5762

63+
====
5864
[source,xml]
5965
----
6066
<http>
6167
...
6268
<remember-me data-source-ref="someDataSource"/>
6369
</http>
6470
----
71+
====
6572

6673
The database should contain a `persistent_logins` table, created using the following SQL (or equivalent):
6774

75+
====
6876
[source,ddl]
6977
----
7078
create table persistent_logins (username varchar(64) not null,
7179
series varchar(64) primary key,
7280
token varchar(64) not null,
7381
last_used timestamp not null)
7482
----
83+
====
7584

7685
[[remember-me-impls]]
7786
== Remember-Me Interfaces and Implementations
@@ -80,6 +89,7 @@ It is also used within `BasicAuthenticationFilter`.
8089
The hooks will invoke a concrete `RememberMeServices` at the appropriate times.
8190
The interface looks like this:
8291

92+
====
8393
[source,java]
8494
----
8595
Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);
@@ -89,6 +99,7 @@ void loginFail(HttpServletRequest request, HttpServletResponse response);
8999
void loginSuccess(HttpServletRequest request, HttpServletResponse response,
90100
Authentication successfulAuthentication);
91101
----
102+
====
92103

93104
Please refer to the Javadoc for a fuller discussion on what the methods do, although note at this stage that `AbstractAuthenticationProcessingFilter` only calls the `loginFail()` and `loginSuccess()` methods.
94105
The `autoLogin()` method is called by `RememberMeAuthenticationFilter` whenever the `SecurityContextHolder` does not contain an `Authentication`.
@@ -105,8 +116,56 @@ In addition, `TokenBasedRememberMeServices` requires A UserDetailsService from w
105116
Some sort of logout command should be provided by the application that invalidates the cookie if the user requests this.
106117
`TokenBasedRememberMeServices` also implements Spring Security's `LogoutHandler` interface so can be used with `LogoutFilter` to have the cookie cleared automatically.
107118

108-
The beans required in an application context to enable remember-me services are as follows:
119+
By default, this implementation uses the MD5 algorithm to encode the token signature.
120+
To verify the token signature, the algorithm retrieved from `algorithmName` is parsed and used.
121+
If no `algorithmName` is present, the default matching algorithm will be used, which is MD5.
122+
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.
123+
To do that you can specify your customized `TokenBasedRememberMeServices` as a Bean and use it in the configuration.
109124

125+
====
126+
.Java
127+
[source,java,role="primary"]
128+
----
129+
@Bean
130+
SecurityFilterChain securityFilterChain(HttpSecurity http, RememberMeServices rememberMeServices) throws Exception {
131+
http
132+
.authorizeHttpRequests((authorize) -> authorize
133+
.anyRequest().authenticated()
134+
)
135+
.rememberMe((remember) -> remember
136+
.rememberMeServices(rememberMeServices)
137+
);
138+
return http.build();
139+
}
140+
141+
@Bean
142+
RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
143+
RememberMeTokenAlgorithm encodingAlgorithm = RememberMeTokenAlgorithm.SHA256;
144+
TokenBasedRememberMeServices rememberMe = new TokenBasedRememberMeServices(myKey, userDetailsService, encodingAlgorithm);
145+
rememberMe.setMatchingAlgorithm(RememberMeTokenAlgorithm.MD5);
146+
return rememberMe;
147+
}
148+
----
149+
.XML
150+
[source,xml,role="secondary"]
151+
----
152+
<http>
153+
<remember-me services-ref="rememberMeServices"/>
154+
</http>
155+
156+
<bean id="rememberMeServices" class=
157+
"org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices">
158+
<property name="userDetailsService" ref="myUserDetailsService"/>
159+
<property name="key" value="springRocks"/>
160+
<property name="matchingAlgorithm" value="MD5"/>
161+
<property name="encodingAlgorithm" value="SHA256"/>
162+
</bean>
163+
----
164+
====
165+
166+
The following beans are required in an application context to enable remember-me services:
167+
168+
====
110169
[source,xml]
111170
----
112171
<bean id="rememberMeFilter" class=
@@ -126,13 +185,13 @@ The beans required in an application context to enable remember-me services are
126185
<property name="key" value="springRocks"/>
127186
</bean>
128187
----
188+
====
129189

130190
Don't forget to add your `RememberMeServices` implementation to your `UsernamePasswordAuthenticationFilter.setRememberMeServices()` property, include the `RememberMeAuthenticationProvider` in your `AuthenticationManager.setProviders()` list, and add `RememberMeAuthenticationFilter` into your `FilterChainProxy` (typically immediately after your `UsernamePasswordAuthenticationFilter`).
131191

132192

133193
=== PersistentTokenBasedRememberMeServices
134-
This class can be used in the same way as `TokenBasedRememberMeServices`, but it additionally needs to be configured with a `PersistentTokenRepository` to store the tokens.
135-
There are two standard implementations.
194+
You can use this class in the same way as `TokenBasedRememberMeServices`, but it additionally needs to be configured with a `PersistentTokenRepository` to store the tokens.
136195

137196
* `InMemoryTokenRepositoryImpl` which is intended for testing only.
138197
* `JdbcTokenRepositoryImpl` which stores the tokens in a database.

web/src/main/java/org/springframework/security/web/authentication/rememberme/TokenBasedRememberMeServices.java

Lines changed: 104 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,21 @@
5454
* The cookie encoded by this implementation adopts the following form:
5555
*
5656
* <pre>
57-
* username + &quot;:&quot; + expiryTime + &quot;:&quot;
58-
* + Md5Hex(username + &quot;:&quot; + expiryTime + &quot;:&quot; + password + &quot;:&quot; + key)
57+
* username + &quot;:&quot; + expiryTime + &quot;:&quot; + algorithmName + &quot;:&quot;
58+
* + algorithmHex(username + &quot;:&quot; + expiryTime + &quot;:&quot; + password + &quot;:&quot; + key)
5959
* </pre>
6060
*
6161
* <p>
62+
* This implementation uses the algorithm configured in {@link #encodingAlgorithm} to
63+
* encode the signature. It will try to use the algorithm retrieved from the
64+
* {@code algorithmName} to validate the signature. However, if the {@code algorithmName}
65+
* is not present in the cookie value, the algorithm configured in
66+
* {@link #matchingAlgorithm} will be used to validate the signature. This allows users to
67+
* safely upgrade to a different encoding algorithm while still able to verify old ones if
68+
* there is no {@code algorithmName} present.
69+
* </p>
70+
*
71+
* <p>
6272
* As such, if the user changes their password, any remember-me token will be invalidated.
6373
* Equally, the system administrator may invalidate every remember-me token on issue by
6474
* changing the key. This provides some reasonable approaches to recovering from a
@@ -80,19 +90,43 @@
8090
* not be stored when the browser is closed.
8191
*
8292
* @author Ben Alex
93+
* @author Marcus Da Coregio
8394
*/
8495
public class TokenBasedRememberMeServices extends AbstractRememberMeServices {
8596

97+
private static final RememberMeTokenAlgorithm DEFAULT_MATCHING_ALGORITHM = RememberMeTokenAlgorithm.MD5;
98+
99+
private static final RememberMeTokenAlgorithm DEFAULT_ENCODING_ALGORITHM = RememberMeTokenAlgorithm.MD5;
100+
101+
private final RememberMeTokenAlgorithm encodingAlgorithm;
102+
103+
private RememberMeTokenAlgorithm matchingAlgorithm = DEFAULT_MATCHING_ALGORITHM;
104+
86105
public TokenBasedRememberMeServices(String key, UserDetailsService userDetailsService) {
106+
this(key, userDetailsService, DEFAULT_ENCODING_ALGORITHM);
107+
}
108+
109+
/**
110+
* Construct the instance with the parameters provided
111+
* @param key the signature key
112+
* @param userDetailsService the {@link UserDetailsService}
113+
* @param encodingAlgorithm the {@link RememberMeTokenAlgorithm} used to encode the
114+
* signature
115+
* @since 5.8
116+
*/
117+
public TokenBasedRememberMeServices(String key, UserDetailsService userDetailsService,
118+
RememberMeTokenAlgorithm encodingAlgorithm) {
87119
super(key, userDetailsService);
120+
Assert.notNull(encodingAlgorithm, "encodingAlgorithm cannot be null");
121+
this.encodingAlgorithm = encodingAlgorithm;
88122
}
89123

90124
@Override
91125
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
92126
HttpServletResponse response) {
93-
if (cookieTokens.length != 3) {
127+
if (!isValidCookieTokensLength(cookieTokens)) {
94128
throw new InvalidCookieException(
95-
"Cookie token did not contain 3" + " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
129+
"Cookie token did not contain 3 or 4 tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
96130
}
97131
long tokenExpiryTime = getTokenExpiryTime(cookieTokens);
98132
if (isTokenExpired(tokenExpiryTime)) {
@@ -110,15 +144,27 @@ protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletR
110144
// only called once per HttpSession - if the token is valid, it will cause
111145
// SecurityContextHolder population, whilst if invalid, will cause the cookie to
112146
// be cancelled.
147+
String actualTokenSignature = cookieTokens[2];
148+
RememberMeTokenAlgorithm actualAlgorithm = this.matchingAlgorithm;
149+
// If the cookie value contains the algorithm, we use that algorithm to check the
150+
// signature
151+
if (cookieTokens.length == 4) {
152+
actualTokenSignature = cookieTokens[3];
153+
actualAlgorithm = RememberMeTokenAlgorithm.valueOf(cookieTokens[2]);
154+
}
113155
String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
114-
userDetails.getPassword());
115-
if (!equals(expectedTokenSignature, cookieTokens[2])) {
116-
throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2]
117-
+ "' but expected '" + expectedTokenSignature + "'");
156+
userDetails.getPassword(), actualAlgorithm);
157+
if (!equals(expectedTokenSignature, actualTokenSignature)) {
158+
throw new InvalidCookieException("Cookie contained signature '" + actualTokenSignature + "' but expected '"
159+
+ expectedTokenSignature + "'");
118160
}
119161
return userDetails;
120162
}
121163

164+
private boolean isValidCookieTokensLength(String[] cookieTokens) {
165+
return cookieTokens.length == 3 || cookieTokens.length == 4;
166+
}
167+
122168
private long getTokenExpiryTime(String[] cookieTokens) {
123169
try {
124170
return new Long(cookieTokens[1]);
@@ -130,17 +176,33 @@ private long getTokenExpiryTime(String[] cookieTokens) {
130176
}
131177

132178
/**
133-
* Calculates the digital signature to be put in the cookie. Default value is MD5
134-
* ("username:tokenExpiryTime:password:key")
179+
* Calculates the digital signature to be put in the cookie. Default value is
180+
* {@link #encodingAlgorithm} applied to ("username:tokenExpiryTime:password:key")
135181
*/
136182
protected String makeTokenSignature(long tokenExpiryTime, String username, String password) {
137183
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
138184
try {
139-
MessageDigest digest = MessageDigest.getInstance("MD5");
185+
MessageDigest digest = MessageDigest.getInstance(this.encodingAlgorithm.getDigestAlgorithm());
140186
return new String(Hex.encode(digest.digest(data.getBytes())));
141187
}
142188
catch (NoSuchAlgorithmException ex) {
143-
throw new IllegalStateException("No MD5 algorithm available!");
189+
throw new IllegalStateException("No " + this.encodingAlgorithm.name() + " algorithm available!");
190+
}
191+
}
192+
193+
/**
194+
* Calculates the digital signature to be put in the cookie.
195+
* @since 5.8
196+
*/
197+
protected String makeTokenSignature(long tokenExpiryTime, String username, String password,
198+
RememberMeTokenAlgorithm algorithm) {
199+
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
200+
try {
201+
MessageDigest digest = MessageDigest.getInstance(algorithm.getDigestAlgorithm());
202+
return new String(Hex.encode(digest.digest(data.getBytes())));
203+
}
204+
catch (NoSuchAlgorithmException ex) {
205+
throw new IllegalStateException("No " + algorithm.name() + " algorithm available!");
144206
}
145207
}
146208

@@ -172,15 +234,25 @@ public void onLoginSuccess(HttpServletRequest request, HttpServletResponse respo
172234
long expiryTime = System.currentTimeMillis();
173235
// SEC-949
174236
expiryTime += 1000L * ((tokenLifetime < 0) ? TWO_WEEKS_S : tokenLifetime);
175-
String signatureValue = makeTokenSignature(expiryTime, username, password);
176-
setCookie(new String[] { username, Long.toString(expiryTime), signatureValue }, tokenLifetime, request,
177-
response);
237+
String signatureValue = makeTokenSignature(expiryTime, username, password, this.encodingAlgorithm);
238+
setCookie(new String[] { username, Long.toString(expiryTime), this.encodingAlgorithm.name(), signatureValue },
239+
tokenLifetime, request, response);
178240
if (this.logger.isDebugEnabled()) {
179241
this.logger.debug(
180242
"Added remember-me cookie for user '" + username + "', expiry: '" + new Date(expiryTime) + "'");
181243
}
182244
}
183245

246+
/**
247+
* Sets the algorithm to be used to match the token signature
248+
* @param matchingAlgorithm the matching algorithm
249+
* @since 5.8
250+
*/
251+
public void setMatchingAlgorithm(RememberMeTokenAlgorithm matchingAlgorithm) {
252+
Assert.notNull(matchingAlgorithm, "matchingAlgorithm cannot be null");
253+
this.matchingAlgorithm = matchingAlgorithm;
254+
}
255+
184256
/**
185257
* Calculates the validity period in seconds for a newly generated remember-me login.
186258
* After this period (from the current time) the remember-me login will be considered
@@ -190,7 +262,7 @@ public void onLoginSuccess(HttpServletRequest request, HttpServletResponse respo
190262
* <p>
191263
* The returned value will be used to work out the expiry time of the token and will
192264
* also be used to set the <tt>maxAge</tt> property of the cookie.
193-
*
265+
* <p>
194266
* See SEC-485.
195267
* @param request the request passed to onLoginSuccess
196268
* @param authentication the successful authentication object.
@@ -234,4 +306,20 @@ private static byte[] bytesUtf8(String s) {
234306
return (s != null) ? Utf8.encode(s) : null;
235307
}
236308

309+
public enum RememberMeTokenAlgorithm {
310+
311+
MD5("MD5"), SHA256("SHA-256");
312+
313+
private final String digestAlgorithm;
314+
315+
RememberMeTokenAlgorithm(String digestAlgorithm) {
316+
this.digestAlgorithm = digestAlgorithm;
317+
}
318+
319+
public String getDigestAlgorithm() {
320+
return this.digestAlgorithm;
321+
}
322+
323+
}
324+
237325
}

web/src/test/java/org/springframework/security/test/web/CodecTestUtils.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616

1717
package org.springframework.security.test.web;
1818

19+
import java.security.MessageDigest;
20+
import java.security.NoSuchAlgorithmException;
1921
import java.util.Base64;
2022

23+
import org.springframework.security.crypto.codec.Hex;
2124
import org.springframework.util.DigestUtils;
2225

2326
public final class CodecTestUtils {
@@ -52,4 +55,14 @@ public static String md5Hex(String data) {
5255
return DigestUtils.md5DigestAsHex(data.getBytes());
5356
}
5457

58+
public static String algorithmHex(String algorithmName, String data) {
59+
try {
60+
MessageDigest digest = MessageDigest.getInstance(algorithmName);
61+
return new String(Hex.encode(digest.digest(data.getBytes())));
62+
}
63+
catch (NoSuchAlgorithmException ex) {
64+
throw new IllegalStateException("No " + algorithmName + " algorithm available!");
65+
}
66+
}
67+
5568
}

0 commit comments

Comments
 (0)