001: /* Copyright 2004, 2005, 2006 Acegi Technology Pty Limited
002: *
003: * Licensed under the Apache License, Version 2.0 (the "License");
004: * you may not use this file except in compliance with the License.
005: * You may obtain a copy of the License at
006: *
007: * http://www.apache.org/licenses/LICENSE-2.0
008: *
009: * Unless required by applicable law or agreed to in writing, software
010: * distributed under the License is distributed on an "AS IS" BASIS,
011: * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
012: * See the License for the specific language governing permissions and
013: * limitations under the License.
014: */
015:
016: package org.acegisecurity.ui.rememberme;
017:
018: import java.util.Date;
019: import java.util.Map;
020:
021: import javax.servlet.http.Cookie;
022: import javax.servlet.http.HttpServletRequest;
023: import javax.servlet.http.HttpServletResponse;
024:
025: import org.acegisecurity.Authentication;
026: import org.acegisecurity.providers.rememberme.RememberMeAuthenticationToken;
027: import org.acegisecurity.ui.AccessDeniedHandler;
028: import org.acegisecurity.ui.AuthenticationDetailsSource;
029: import org.acegisecurity.ui.AuthenticationDetailsSourceImpl;
030: import org.acegisecurity.ui.logout.LogoutHandler;
031: import org.acegisecurity.userdetails.UserDetails;
032: import org.acegisecurity.userdetails.UserDetailsService;
033: import org.acegisecurity.userdetails.UsernameNotFoundException;
034: import org.apache.commons.codec.binary.Base64;
035: import org.apache.commons.codec.digest.DigestUtils;
036: import org.apache.commons.logging.Log;
037: import org.apache.commons.logging.LogFactory;
038: import org.springframework.beans.factory.InitializingBean;
039: import org.springframework.context.ApplicationContext;
040: import org.springframework.util.Assert;
041: import org.springframework.util.StringUtils;
042: import org.springframework.web.bind.RequestUtils;
043:
044: /**
045: * Identifies previously remembered users by a Base-64 encoded cookie.
046: *
047: * <p>
048: * This implementation does not rely on an external database, so is attractive
049: * for simple applications. The cookie will be valid for a specific period from
050: * the date of the last
051: * {@link #loginSuccess(HttpServletRequest, HttpServletResponse, Authentication)}.
052: * As per the interface contract, this method will only be called when the
053: * principal completes a successful interactive authentication. As such the time
054: * period commences from the last authentication attempt where they furnished
055: * credentials - not the time period they last logged in via remember-me. The
056: * implementation will only send a remember-me token if the parameter defined by
057: * {@link #setParameter(String)} is present.
058: * </p>
059: *
060: * <p>
061: * An {@link org.acegisecurity.userdetails.UserDetailsService} is required by
062: * this implementation, so that it can construct a valid
063: * <code>Authentication</code> from the returned {@link
064: * org.acegisecurity.userdetails.UserDetails}. This is also necessary so that
065: * the user's password is available and can be checked as part of the encoded
066: * cookie.
067: * </p>
068: *
069: * <p>
070: * The cookie encoded by this implementation adopts the following form:
071: *
072: * <pre>
073: * username + ":" + expiryTime + ":" + Md5Hex(username + ":" + expiryTime + ":" + password + ":" + key)
074: * </pre>
075: *
076: * </p>
077: * <p>
078: * As such, if the user changes their password any remember-me token will be
079: * invalidated. Equally, the system administrator may invalidate every
080: * remember-me token on issue by changing the key. This provides some reasonable
081: * approaches to recovering from a remember-me token being left on a public
082: * machine (eg kiosk system, Internet cafe etc). Most importantly, at no time is
083: * the user's password ever sent to the user agent, providing an important
084: * security safeguard. Unfortunately the username is necessary in this
085: * implementation (as we do not want to rely on a database for remember-me
086: * services) and as such high security applications should be aware of this
087: * occasionally undesired disclosure of a valid username.
088: * </p>
089: * <p>
090: * This is a basic remember-me implementation which is suitable for many
091: * applications. However, we recommend a database-based implementation if you
092: * require a more secure remember-me approach.
093: * </p>
094: * <p>
095: * By default the tokens will be valid for 14 days from the last successful
096: * authentication attempt. This can be changed using
097: * {@link #setTokenValiditySeconds(long)}.
098: * </p>
099: *
100: * @author Ben Alex
101: * @version $Id: TokenBasedRememberMeServices.java 1871 2007-05-25 03:12:49Z
102: * benalex $
103: */
104: public class TokenBasedRememberMeServices implements
105: RememberMeServices, InitializingBean, LogoutHandler {
106: // ~ Static fields/initializers
107: // =====================================================================================
108:
109: public static final String ACEGI_SECURITY_HASHED_REMEMBER_ME_COOKIE_KEY = "ACEGI_SECURITY_HASHED_REMEMBER_ME_COOKIE";
110:
111: public static final String DEFAULT_PARAMETER = "_acegi_security_remember_me";
112:
113: protected static final Log logger = LogFactory
114: .getLog(TokenBasedRememberMeServices.class);
115:
116: // ~ Instance fields
117: // ================================================================================================
118:
119: protected AuthenticationDetailsSource authenticationDetailsSource = new AuthenticationDetailsSourceImpl();
120:
121: private String key;
122:
123: private String parameter = DEFAULT_PARAMETER;
124:
125: private UserDetailsService userDetailsService;
126:
127: protected long tokenValiditySeconds = 1209600; // 14 days
128:
129: private boolean alwaysRemember = false;
130:
131: private static final int DEFAULT_ORDER = Integer.MAX_VALUE; // ~ default
132:
133: private String cookieName = ACEGI_SECURITY_HASHED_REMEMBER_ME_COOKIE_KEY;
134:
135: // ~ Methods
136: // ========================================================================================================
137:
138: public void afterPropertiesSet() throws Exception {
139: Assert.hasLength(key);
140: Assert.hasLength(parameter);
141: Assert.hasLength(cookieName);
142: Assert.notNull(userDetailsService);
143: }
144:
145: /**
146: * Introspects the <code>Applicationcontext</code> for the single instance
147: * of {@link AccessDeniedHandler}. If found invoke
148: * setAccessDeniedHandler(AccessDeniedHandler accessDeniedHandler) method by
149: * providing the found instance of accessDeniedHandler as a method
150: * parameter. If more than one instance of <code>AccessDeniedHandler</code>
151: * is found, the method throws <code>IllegalStateException</code>.
152: *
153: * @param applicationContext to locate the instance
154: */
155: private void autoDetectAndUseAnyUserDetailsService(
156: ApplicationContext applicationContext) {
157: Map map = applicationContext
158: .getBeansOfType(UserDetailsService.class);
159: if (map.size() > 1) {
160: throw new IllegalArgumentException(
161: "More than one UserDetailsService beans detected please refer to the one using "
162: + " [ principalRepositoryBeanRef ] "
163: + "attribute");
164: } else if (map.size() == 1) {
165: setUserDetailsService((UserDetailsService) map.values()
166: .iterator().next());
167: }
168: }
169:
170: public Authentication autoLogin(HttpServletRequest request,
171: HttpServletResponse response) {
172: Cookie[] cookies = request.getCookies();
173:
174: if ((cookies == null) || (cookies.length == 0)) {
175: return null;
176: }
177:
178: for (int i = 0; i < cookies.length; i++) {
179: if (cookieName.equals(cookies[i].getName())) {
180: String cookieValue = cookies[i].getValue();
181:
182: for (int j = 0; j < cookieValue.length() % 4; j++) {
183: cookieValue = cookieValue + "=";
184: }
185:
186: if (Base64.isArrayByteBase64(cookieValue.getBytes())) {
187: if (logger.isDebugEnabled()) {
188: logger.debug("Remember-me cookie detected");
189: }
190:
191: // Decode token from Base64
192: // format of token is:
193: // username + ":" + expiryTime + ":" +
194: // Md5Hex(username + ":" + expiryTime + ":" + password + ":"
195: // + key)
196: String cookieAsPlainText = new String(Base64
197: .decodeBase64(cookieValue.getBytes()));
198: String[] cookieTokens = StringUtils
199: .delimitedListToStringArray(
200: cookieAsPlainText, ":");
201:
202: if (cookieTokens.length == 3) {
203:
204: long tokenExpiryTime;
205:
206: try {
207: tokenExpiryTime = new Long(cookieTokens[1])
208: .longValue();
209: } catch (NumberFormatException nfe) {
210: cancelCookie(request, response,
211: "Cookie token[1] did not contain a valid number (contained '"
212: + cookieTokens[1] + "')");
213:
214: return null;
215: }
216:
217: if (isTokenExpired(tokenExpiryTime)) {
218: cancelCookie(request, response,
219: "Cookie token[1] has expired (expired on '"
220: + new Date(tokenExpiryTime)
221: + "'; current time is '"
222: + new Date() + "')");
223:
224: return null;
225: }
226:
227: // Check the user exists
228: // Defer lookup until after expiry time checked, to
229: // possibly avoid expensive lookup
230: UserDetails userDetails = loadUserDetails(
231: request, response, cookieTokens);
232:
233: if (userDetails == null) {
234: cancelCookie(request, response,
235: "Cookie token[0] contained username '"
236: + cookieTokens[0]
237: + "' but was not found");
238: return null;
239: }
240:
241: if (!isValidUserDetails(request, response,
242: userDetails, cookieTokens)) {
243: return null;
244: }
245:
246: // Check signature of token matches remaining details
247: // Must do this after user lookup, as we need the
248: // DAO-derived password
249: // If efficiency was a major issue, just add in a
250: // UserCache implementation,
251: // but recall this method is usually only called one per
252: // HttpSession
253: // (as if the token is valid, it will cause
254: // SecurityContextHolder population, whilst
255: // if invalid, will cause the cookie to be cancelled)
256: String expectedTokenSignature = makeTokenSignature(
257: tokenExpiryTime, userDetails);
258:
259: if (!expectedTokenSignature
260: .equals(cookieTokens[2])) {
261: cancelCookie(request, response,
262: "Cookie token[2] contained signature '"
263: + cookieTokens[2]
264: + "' but expected '"
265: + expectedTokenSignature
266: + "'");
267:
268: return null;
269: }
270:
271: // By this stage we have a valid token
272: if (logger.isDebugEnabled()) {
273: logger.debug("Remember-me cookie accepted");
274: }
275:
276: RememberMeAuthenticationToken auth = new RememberMeAuthenticationToken(
277: this .key, userDetails, userDetails
278: .getAuthorities());
279: auth
280: .setDetails(authenticationDetailsSource
281: .buildDetails((HttpServletRequest) request));
282:
283: return auth;
284: } else {
285: cancelCookie(request, response,
286: "Cookie token did not contain 3 tokens; decoded value was '"
287: + cookieAsPlainText + "'");
288:
289: return null;
290: }
291: } else {
292: cancelCookie(request, response,
293: "Cookie token was not Base64 encoded; value was '"
294: + cookieValue + "'");
295:
296: return null;
297: }
298: }
299: }
300:
301: return null;
302: }
303:
304: /**
305: * @param tokenExpiryTime
306: * @param userDetails
307: * @return
308: */
309: protected String makeTokenSignature(long tokenExpiryTime,
310: UserDetails userDetails) {
311: String expectedTokenSignature = DigestUtils.md5Hex(userDetails
312: .getUsername()
313: + ":"
314: + tokenExpiryTime
315: + ":"
316: + userDetails.getPassword() + ":" + this .key);
317: return expectedTokenSignature;
318: }
319:
320: protected boolean isValidUserDetails(HttpServletRequest request,
321: HttpServletResponse response, UserDetails userDetails,
322: String[] cookieTokens) {
323: // Immediately reject if the user is not allowed to
324: // login
325: if (!userDetails.isAccountNonExpired()
326: || !userDetails.isCredentialsNonExpired()
327: || !userDetails.isEnabled()) {
328: cancelCookie(
329: request,
330: response,
331: "Cookie token[0] contained username '"
332: + cookieTokens[0]
333: + "' but account has expired, credentials have expired, or user is disabled");
334:
335: return false;
336: }
337: return true;
338: }
339:
340: protected UserDetails loadUserDetails(HttpServletRequest request,
341: HttpServletResponse response, String[] cookieTokens) {
342: UserDetails userDetails = null;
343:
344: try {
345: userDetails = this .userDetailsService
346: .loadUserByUsername(cookieTokens[0]);
347: } catch (UsernameNotFoundException notFound) {
348: cancelCookie(request, response,
349: "Cookie token[0] contained username '"
350: + cookieTokens[0] + "' but was not found");
351:
352: return null;
353: }
354: return userDetails;
355: }
356:
357: protected boolean isTokenExpired(long tokenExpiryTime) {
358: // Check it has not expired
359: if (tokenExpiryTime < System.currentTimeMillis()) {
360: return true;
361: }
362: return false;
363: }
364:
365: protected void cancelCookie(HttpServletRequest request,
366: HttpServletResponse response, String reasonForLog) {
367: if ((reasonForLog != null) && logger.isDebugEnabled()) {
368: logger.debug("Cancelling cookie for reason: "
369: + reasonForLog);
370: }
371:
372: response.addCookie(makeCancelCookie(request));
373: }
374:
375: public String getKey() {
376: return key;
377: }
378:
379: public String getParameter() {
380: return parameter;
381: }
382:
383: public long getTokenValiditySeconds() {
384: return tokenValiditySeconds;
385: }
386:
387: public UserDetailsService getUserDetailsService() {
388: return userDetailsService;
389: }
390:
391: public void loginFail(HttpServletRequest request,
392: HttpServletResponse response) {
393: cancelCookie(request, response,
394: "Interactive authentication attempt was unsuccessful");
395: }
396:
397: protected boolean rememberMeRequested(HttpServletRequest request,
398: String parameter) {
399: if (alwaysRemember) {
400: return true;
401: }
402:
403: return RequestUtils.getBooleanParameter(request, parameter,
404: false);
405: }
406:
407: public void loginSuccess(HttpServletRequest request,
408: HttpServletResponse response,
409: Authentication successfulAuthentication) {
410: // Exit if the principal hasn't asked to be remembered
411: if (!rememberMeRequested(request, parameter)) {
412: if (logger.isDebugEnabled()) {
413: logger
414: .debug("Did not send remember-me cookie (principal did not set parameter '"
415: + this .parameter + "')");
416: }
417:
418: return;
419: }
420:
421: // Determine username and password, ensuring empty strings
422: Assert.notNull(successfulAuthentication.getPrincipal());
423: Assert.notNull(successfulAuthentication.getCredentials());
424:
425: String username = retrieveUserName(successfulAuthentication);
426: String password = retrievePassword(successfulAuthentication);
427:
428: // If unable to find a username and password, just abort as
429: // TokenBasedRememberMeServices unable to construct a valid token in
430: // this case
431: if (!StringUtils.hasLength(username)
432: || !StringUtils.hasLength(password)) {
433: return;
434: }
435:
436: long expiryTime = System.currentTimeMillis()
437: + (tokenValiditySeconds * 1000);
438:
439: // construct token to put in cookie; format is:
440: // username + ":" + expiryTime + ":" + Md5Hex(username + ":" +
441: // expiryTime + ":" + password + ":" + key)
442: String signatureValue = DigestUtils.md5Hex(username + ":"
443: + expiryTime + ":" + password + ":" + key);
444: String tokenValue = username + ":" + expiryTime + ":"
445: + signatureValue;
446: String tokenValueBase64 = new String(Base64
447: .encodeBase64(tokenValue.getBytes()));
448: response.addCookie(makeValidCookie(tokenValueBase64, request,
449: tokenValiditySeconds));
450:
451: if (logger.isDebugEnabled()) {
452: logger.debug("Added remember-me cookie for user '"
453: + username + "', expiry: '" + new Date(expiryTime)
454: + "'");
455: }
456: }
457:
458: public void logout(HttpServletRequest request,
459: HttpServletResponse response, Authentication authentication) {
460: cancelCookie(request, response, "Logout of user "
461: + (authentication == null ? "Unknown" : authentication
462: .getName()));
463: }
464:
465: protected String retrieveUserName(
466: Authentication successfulAuthentication) {
467: if (isInstanceOfUserDetails(successfulAuthentication)) {
468: return ((UserDetails) successfulAuthentication
469: .getPrincipal()).getUsername();
470: } else {
471: return successfulAuthentication.getPrincipal().toString();
472: }
473: }
474:
475: protected String retrievePassword(
476: Authentication successfulAuthentication) {
477: if (isInstanceOfUserDetails(successfulAuthentication)) {
478: return ((UserDetails) successfulAuthentication
479: .getPrincipal()).getPassword();
480: } else {
481: return successfulAuthentication.getCredentials().toString();
482: }
483: }
484:
485: private boolean isInstanceOfUserDetails(
486: Authentication authentication) {
487: return authentication.getPrincipal() instanceof UserDetails;
488: }
489:
490: protected Cookie makeCancelCookie(HttpServletRequest request) {
491: Cookie cookie = new Cookie(cookieName, null);
492: cookie.setMaxAge(0);
493: cookie
494: .setPath(StringUtils
495: .hasLength(request.getContextPath()) ? request
496: .getContextPath() : "/");
497:
498: return cookie;
499: }
500:
501: protected Cookie makeValidCookie(String tokenValueBase64,
502: HttpServletRequest request, long maxAge) {
503: Cookie cookie = new Cookie(cookieName, tokenValueBase64);
504: cookie.setMaxAge(new Long(maxAge).intValue());
505: cookie
506: .setPath(StringUtils
507: .hasLength(request.getContextPath()) ? request
508: .getContextPath() : "/");
509:
510: return cookie;
511: }
512:
513: public void setAuthenticationDetailsSource(
514: AuthenticationDetailsSource authenticationDetailsSource) {
515: Assert.notNull(authenticationDetailsSource,
516: "AuthenticationDetailsSource required");
517: this .authenticationDetailsSource = authenticationDetailsSource;
518: }
519:
520: public void setKey(String key) {
521: this .key = key;
522: }
523:
524: public void setParameter(String parameter) {
525: this .parameter = parameter;
526: }
527:
528: public void setCookieName(String cookieName) {
529: this .cookieName = cookieName;
530: }
531:
532: public void setTokenValiditySeconds(long tokenValiditySeconds) {
533: this .tokenValiditySeconds = tokenValiditySeconds;
534: }
535:
536: public void setUserDetailsService(
537: UserDetailsService userDetailsService) {
538: this .userDetailsService = userDetailsService;
539: }
540:
541: public boolean isAlwaysRemember() {
542: return alwaysRemember;
543: }
544:
545: public void setAlwaysRemember(boolean alwaysRemember) {
546: this .alwaysRemember = alwaysRemember;
547: }
548:
549: public String getCookieName() {
550: return cookieName;
551: }
552:
553: }
|