View Javadoc

1   /* Copyright 2004, 2005 Acegi Technology Pty Limited
2    *
3    * Licensed under the Apache License, Version 2.0 (the "License");
4    * you may not use this file except in compliance with the License.
5    * You may obtain a copy of the License at
6    *
7    *     http://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  
16  package org.acegisecurity.ui.rememberme;
17  
18  import org.acegisecurity.Authentication;
19  import org.acegisecurity.providers.rememberme.RememberMeAuthenticationToken;
20  import org.acegisecurity.userdetails.UserDetailsService;
21  import org.acegisecurity.userdetails.UserDetails;
22  import org.acegisecurity.userdetails.UsernameNotFoundException;
23  
24  import org.apache.commons.codec.binary.Base64;
25  import org.apache.commons.codec.digest.DigestUtils;
26  import org.apache.commons.logging.Log;
27  import org.apache.commons.logging.LogFactory;
28  
29  import org.springframework.beans.factory.InitializingBean;
30  
31  import org.springframework.util.Assert;
32  import org.springframework.util.StringUtils;
33  
34  import org.springframework.web.bind.RequestUtils;
35  
36  import java.util.Date;
37  
38  import javax.servlet.http.Cookie;
39  import javax.servlet.http.HttpServletRequest;
40  import javax.servlet.http.HttpServletResponse;
41  
42  
43  /***
44   * Identifies previously remembered users by a Base-64 encoded cookie.
45   * 
46   * <p>
47   * This implementation does not rely on an external database, so is attractive
48   * for simple applications. The cookie will be valid for a specific period
49   * from the date of the last {@link #loginSuccess(HttpServletRequest,
50   * HttpServletResponse, Authentication)}. As per the interface contract, this
51   * method will only be called when the principal completes a successful
52   * interactive authentication. As such the time period commences from the last
53   * authentication attempt where they furnished credentials - not the time
54   * period they last logged in via remember-me. The implementation will only
55   * send a remember-me token if the parameter defined by {@link
56   * #setParameter(String)} is present.
57   * </p>
58   * 
59   * <p>
60   * An {@link org.acegisecurity.userdetails.UserDetailsService} is required
61   * by this implementation, so that it can construct a valid
62   * <code>Authentication</code> from the returned {@link
63   * org.acegisecurity.userdetails.UserDetails}. This is also necessary so that the
64   * user's password is available and can be checked as part of the encoded
65   * cookie.
66   * </p>
67   * 
68   * <p>
69   * The cookie encoded by this implementation adopts the following form:
70   * </p>
71   * 
72   * <p>
73   * <code> username + ":" + expiryTime + ":" + Md5Hex(username + ":" +
74   * expiryTime + ":" + password + ":" + key) </code>.
75   * </p>
76   * 
77   * <p>
78   * As such, if the user changes their password any remember-me token will be
79   * invalidated. Equally, the system administrator may invalidate every
80   * remember-me token on issue by changing the key. This provides some
81   * reasonable approaches to recovering from a remember-me token being left on
82   * a public machine (eg kiosk system, Internet cafe etc). Most importantly, at
83   * no time is the user's password ever sent to the user agent, providing an
84   * important security safeguard. Unfortunately the username is necessary in
85   * this implementation (as we do not want to rely on a database for
86   * remember-me services) and as such high security applications should be
87   * aware of this occasionally undesired disclosure of a valid username.
88   * </p>
89   * 
90   * <p>
91   * This is a basic remember-me implementation which is suitable for many
92   * applications. However, we recommend a database-based implementation if you
93   * require a more secure remember-me approach.
94   * </p>
95   * 
96   * <p>
97   * By default the tokens will be valid for 14 days from the last successful
98   * authentication attempt. This can be changed using {@link
99   * #setTokenValiditySeconds(int)}.
100  * </p>
101  *
102  * @author Ben Alex
103  * @version $Id: TokenBasedRememberMeServices.java,v 1.7 2005/11/30 00:20:12 benalex Exp $
104  */
105 public class TokenBasedRememberMeServices implements RememberMeServices,
106     InitializingBean {
107     //~ Static fields/initializers =============================================
108 
109     public static final String ACEGI_SECURITY_HASHED_REMEMBER_ME_COOKIE_KEY = "ACEGI_SECURITY_HASHED_REMEMBER_ME_COOKIE";
110     public static final String DEFAULT_PARAMETER = "_acegi_security_remember_me";
111     protected static final Log logger = LogFactory.getLog(TokenBasedRememberMeServices.class);
112 
113     //~ Instance fields ========================================================
114 
115     private UserDetailsService userDetailsService;
116     private String key;
117     private String parameter = DEFAULT_PARAMETER;
118     private long tokenValiditySeconds = 1209600; // 14 days
119 
120     //~ Methods ================================================================
121 
122     public void setUserDetailsService(UserDetailsService authenticationDao) {
123         this.userDetailsService = authenticationDao;
124     }
125 
126     public UserDetailsService getUserDetailsService() {
127         return userDetailsService;
128     }
129 
130     public void setKey(String key) {
131         this.key = key;
132     }
133 
134     public String getKey() {
135         return key;
136     }
137 
138     public void setParameter(String parameter) {
139         this.parameter = parameter;
140     }
141 
142     public String getParameter() {
143         return parameter;
144     }
145 
146     public void setTokenValiditySeconds(long tokenValiditySeconds) {
147         this.tokenValiditySeconds = tokenValiditySeconds;
148     }
149 
150     public long getTokenValiditySeconds() {
151         return tokenValiditySeconds;
152     }
153 
154     public void afterPropertiesSet() throws Exception {
155         Assert.hasLength(key);
156         Assert.hasLength(parameter);
157         Assert.notNull(userDetailsService);
158     }
159 
160     public Authentication autoLogin(HttpServletRequest request,
161         HttpServletResponse response) {
162         Cookie[] cookies = request.getCookies();
163 
164         if ((cookies == null) || (cookies.length == 0)) {
165             return null;
166         }
167 
168         for (int i = 0; i < cookies.length; i++) {
169             if (ACEGI_SECURITY_HASHED_REMEMBER_ME_COOKIE_KEY.equals(
170                     cookies[i].getName())) {
171                 String cookieValue = cookies[i].getValue();
172 
173                 if (Base64.isArrayByteBase64(cookieValue.getBytes())) {
174                     if (logger.isDebugEnabled()) {
175                         logger.debug("Remember-me cookie detected");
176                     }
177 
178                     // Decode token from Base64
179                     // format of token is:  
180                     //     username + ":" + expiryTime + ":" + Md5Hex(username + ":" + expiryTime + ":" + password + ":" + key)
181                     String cookieAsPlainText = new String(Base64.decodeBase64(
182                                 cookieValue.getBytes()));
183                     String[] cookieTokens = StringUtils
184                         .delimitedListToStringArray(cookieAsPlainText, ":");
185 
186                     if (cookieTokens.length == 3) {
187                         long tokenExpiryTime;
188 
189                         try {
190                             tokenExpiryTime = new Long(cookieTokens[1])
191                                 .longValue();
192                         } catch (NumberFormatException nfe) {
193                             cancelCookie(request, response,
194                                 "Cookie token[1] did not contain a valid number (contained '"
195                                 + cookieTokens[1] + "')");
196 
197                             return null;
198                         }
199 
200                         // Check it has not expired
201                         if (tokenExpiryTime < System.currentTimeMillis()) {
202                             cancelCookie(request, response,
203                                 "Cookie token[1] has expired (expired on '"
204                                 + new Date(tokenExpiryTime)
205                                 + "'; current time is '" + new Date() + "')");
206 
207                             return null;
208                         }
209 
210                         // Check the user exists
211                         // Defer lookup until after expiry time checked, to possibly avoid expensive lookup
212                         UserDetails userDetails;
213 
214                         try {
215                             userDetails = this.userDetailsService
216                                 .loadUserByUsername(cookieTokens[0]);
217                         } catch (UsernameNotFoundException notFound) {
218                             cancelCookie(request, response,
219                                 "Cookie token[0] contained username '"
220                                 + cookieTokens[0] + "' but was not found");
221 
222                             return null;
223                         }
224 
225                         // Immediately reject if the user is not allowed to login
226                         if (!userDetails.isAccountNonExpired()
227                             || !userDetails.isCredentialsNonExpired()
228                             || !userDetails.isEnabled()) {
229                             cancelCookie(request, response,
230                                 "Cookie token[0] contained username '"
231                                 + cookieTokens[0]
232                                 + "' but account has expired, credentials have expired, or user is disabled");
233 
234                             return null;
235                         }
236 
237                         // Check signature of token matches remaining details
238                         // Must do this after user lookup, as we need the DAO-derived password
239                         // If efficiency was a major issue, just add in a UserCache implementation,
240                         // but recall this method is usually only called one per HttpSession
241                         // (as if the token is valid, it will cause SecurityContextHolder population, whilst
242                         // if invalid, will cause the cookie to be cancelled)
243                         String expectedTokenSignature = DigestUtils.md5Hex(userDetails
244                                 .getUsername() + ":" + tokenExpiryTime + ":"
245                                 + userDetails.getPassword() + ":" + this.key);
246 
247                         if (!expectedTokenSignature.equals(cookieTokens[2])) {
248                             cancelCookie(request, response,
249                                 "Cookie token[2] contained signature '"
250                                 + cookieTokens[2] + "' but expected '"
251                                 + expectedTokenSignature + "'");
252 
253                             return null;
254                         }
255 
256                         // By this stage we have a valid token
257                         if (logger.isDebugEnabled()) {
258                             logger.debug("Remember-me cookie accepted");
259                         }
260 
261                         return new RememberMeAuthenticationToken(this.key,
262                             userDetails, userDetails.getAuthorities());
263                     } else {
264                         cancelCookie(request, response,
265                             "Cookie token did not contain 3 tokens; decoded value was '"
266                             + cookieAsPlainText + "'");
267 
268                         return null;
269                     }
270                 } else {
271                     cancelCookie(request, response,
272                         "Cookie token was not Base64 encoded; value was '"
273                         + cookieValue + "'");
274 
275                     return null;
276                 }
277             }
278         }
279 
280         return null;
281     }
282 
283     public void loginFail(HttpServletRequest request,
284         HttpServletResponse response) {
285         cancelCookie(request, response,
286             "Interactive authentication attempt was unsuccessful");
287     }
288 
289     public void loginSuccess(HttpServletRequest request,
290         HttpServletResponse response, Authentication successfulAuthentication) {
291         // Exit if the principal hasn't asked to be remembered
292         if (!RequestUtils.getBooleanParameter(request, parameter, false)) {
293             if (logger.isDebugEnabled()) {
294                 logger.debug(
295                     "Did not send remember-me cookie (principal did not set parameter '"
296                     + this.parameter + "')");
297             }
298 
299             return;
300         }
301 
302         // Determine username and password, ensuring empty strings
303         Assert.notNull(successfulAuthentication.getPrincipal());
304         Assert.notNull(successfulAuthentication.getCredentials());
305 
306         String username;
307         String password;
308 
309         if (successfulAuthentication.getPrincipal() instanceof UserDetails) {
310             username = ((UserDetails) successfulAuthentication.getPrincipal())
311                 .getUsername();
312             password = ((UserDetails) successfulAuthentication.getPrincipal())
313                 .getPassword();
314         } else {
315             username = successfulAuthentication.getPrincipal().toString();
316             password = successfulAuthentication.getCredentials().toString();
317         }
318 
319         Assert.hasLength(username);
320         Assert.hasLength(password);
321 
322         long expiryTime = System.currentTimeMillis()
323             + (tokenValiditySeconds * 1000);
324 
325         // construct token to put in cookie; format is:
326         //     username + ":" + expiryTime + ":" + Md5Hex(username + ":" + expiryTime + ":" + password + ":" + key)
327         String signatureValue = new String(DigestUtils.md5Hex(username + ":"
328                     + expiryTime + ":" + password + ":" + key));
329         String tokenValue = username + ":" + expiryTime + ":" + signatureValue;
330         String tokenValueBase64 = new String(Base64.encodeBase64(
331                     tokenValue.getBytes()));
332         response.addCookie(makeValidCookie(expiryTime, tokenValueBase64));
333 
334         if (logger.isDebugEnabled()) {
335             logger.debug("Added remember-me cookie for user '" + username
336                 + "', expiry: '" + new Date(expiryTime) + "'");
337         }
338     }
339 
340     protected Cookie makeCancelCookie() {
341         Cookie cookie = new Cookie(ACEGI_SECURITY_HASHED_REMEMBER_ME_COOKIE_KEY,
342                 null);
343         cookie.setMaxAge(0);
344 
345         return cookie;
346     }
347 
348     protected Cookie makeValidCookie(long expiryTime, String tokenValueBase64) {
349         Cookie cookie = new Cookie(ACEGI_SECURITY_HASHED_REMEMBER_ME_COOKIE_KEY,
350                 tokenValueBase64);
351         cookie.setMaxAge(60 * 60 * 24 * 365 * 5); // 5 years
352 
353         return cookie;
354     }
355 
356     private void cancelCookie(HttpServletRequest request,
357         HttpServletResponse response, String reasonForLog) {
358         if ((reasonForLog != null) && logger.isDebugEnabled()) {
359             logger.debug("Cancelling cookie for reason: " + reasonForLog);
360         }
361 
362         response.addCookie(makeCancelCookie());
363     }
364 }