Implementing Ajax Authentication using jQuery, Spring Security and HTTPS
Join the DZone community and get the full member experience.
Join For FreeI've always had a keen interest in implementing security in webapps. I implemented container-managed authentication (CMA) in AppFuse in 2002, watched Tomcat improve it's implementation in 2003 and implemented Remember Me with CMA in 2004. In 2005, I switched from CMA to Acegi Security (now Spring Security) and never looked back. I've been very happy with Spring Security over the years, but also hope to learn more about Apache Shiro and implementing OAuth to protect JavaScript APIs in the near future.
I was recently re-inspired to learn more about security when working on a new feature at Overstock.com. The feature hasn't been released yet, but basically boils down to allowing users to login without leaving a page. For example, if they want to leave a review on a product, they would click a link, be prompted to login, enter their credentials, then continue to leave their review. The login prompt and subsequent review would likely be implemented using a lightbox. While lightboxes are often seen in webapps these days because they look good, it's also possible Lightbox UIs provide a poor user experience. User experience aside, I think it's interesting to see what's required to implement such a feature.
To demonstrate how we did it, I whipped up an example using AppFuse Light, jQuery and Spring Security. The source is available in my ajax-login project on GitHub. To begin, I wanted to accomplish a number of things to replicate the Overstock environment:
- Force HTTPS for authentication.
- Allow testing HTTPS without installing a certificate locally.
- Implement a RESTful LoginService that allows users to login.
- Implement login with Ajax, with the request coming from an insecure page.
Forcing HTTPS with Spring Security
The first feature was fairly easy to implement thanks to Spring Security. Its configuration supports a requires-channel
attribute that can be used for this. I used this to force HTTPS on the
"users" page and it subsequently causes the login to be secure.
<intercept-url pattern="/app/users" access="ROLE_ADMIN" requires-channel="https"/>
Testing HTTPS without adding a certificate locally
After making the above change in security.xml, I had to modify my jWebUnit test
to work with SSL. In reality, I didn't have to modify the test, I just
had to modify the configuration that ran the test. In my last post, I wrote about adding my 'untrusted' cert to my JVM keystore.
For some reason, this works for HttpClient, but not for
jWebUnit/HtmlUnit. The good news is I figured out an easier solution -
adding the trustStore and trustStore password as system properties to
the maven-failsafe-plugin configuration.
The disadvantage to doing things this way is you'll have to pass these in as arguments when running unit tests in your IDE.
Implementing a LoginService
Next, I set about implementing a LoginService as a Spring MVC Controller that returns JSON thanks to the @ResponseBody annotation and Jackson.
package org.appfuse.examples.web;
import org.appfuse.model.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
@RequestMapping("/api/login.json")
public class LoginService {
@Autowired
@Qualifier("authenticationManager")
AuthenticationManager authenticationManager;
@RequestMapping(method = RequestMethod.GET)
@ResponseBody
public LoginStatus getStatus() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && !auth.getName().equals("anonymousUser") && auth.isAuthenticated()) {
return new LoginStatus(true, auth.getName());
} else {
return new LoginStatus(false, null);
}
}
@RequestMapping(method = RequestMethod.POST)
@ResponseBody
public LoginStatus login(@RequestParam("j_username") String username,
@RequestParam("j_password") String password) {
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
User details = new User(username);
token.setDetails(details);
try {
Authentication auth = authenticationManager.authenticate(token);
SecurityContextHolder.getContext().setAuthentication(auth);
return new LoginStatus(auth.isAuthenticated(), auth.getName());
} catch (BadCredentialsException e) {
return new LoginStatus(false, null);
}
}
public class LoginStatus {
private final boolean loggedIn;
private final String username;
public LoginStatus(boolean loggedIn, String username) {
this.loggedIn = loggedIn;
this.username = username;
}
public boolean isLoggedIn() {
return loggedIn;
}
public String getUsername() {
return username;
}
}
}
To verify this class worked as expected, I wrote a unit test using JUnit and Mockito. I used Mockito because Overstock is transitioning to it from EasyMock and I've found it very simple to use.
package org.appfuse.examples.web;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Matchers;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextImpl;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
public class LoginServiceTest {
LoginService loginService;
AuthenticationManager authenticationManager;
@Before
public void before() {
loginService = new LoginService();
authenticationManager = mock(AuthenticationManager.class);
loginService.authenticationManager = authenticationManager;
}
@After
public void after() {
SecurityContextHolder.clearContext();
}
@Test
public void testLoginStatusSuccess() {
Authentication auth = new TestingAuthenticationToken("foo", "bar");
auth.setAuthenticated(true);
SecurityContext context = new SecurityContextImpl();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
LoginService.LoginStatus status = loginService.getStatus();
assertTrue(status.isLoggedIn());
}
@Test
public void testLoginStatusFailure() {
LoginService.LoginStatus status = loginService.getStatus();
assertFalse(status.isLoggedIn());
}
@Test
public void testGoodLogin() {
Authentication auth = new TestingAuthenticationToken("foo", "bar");
auth.setAuthenticated(true);
when(authenticationManager.authenticate(Matchers.<authentication>anyObject())).thenReturn(auth);
LoginService.LoginStatus status = loginService.login("foo", "bar");
assertTrue(status.isLoggedIn());
assertEquals("foo", status.getUsername());
}
@Test
public void testBadLogin() {
Authentication auth = new TestingAuthenticationToken("foo", "bar");
auth.setAuthenticated(false);
when(authenticationManager.authenticate(Matchers.<authentication>anyObject()))
.thenThrow(new BadCredentialsException("Bad Credentials"));
LoginService.LoginStatus status = loginService.login("foo", "bar");
assertFalse(status.isLoggedIn());
assertEquals(null, status.getUsername());
}
}
</authentication></authentication>
Implement login with Ajax
The last feature was the hardest to implement and still isn't fully
working as I'd hoped. I used jQuery and jQuery UI to implement a dialog
that opens the login page on the same page rather than redirecting to
the login page. The "#demo" locator refers to a button in the page.
Passing in the "ajax=true" parameter disables SiteMesh decoration on the login page, something that's described in my Ajaxified Body article.
var dialog = $('<div></div>');
$(document).ready(function() {
$.get('/login?ajax=true', function(data) {
dialog.html(data);
dialog.dialog({
autoOpen: false,
title: 'Authentication Required'
});
});
$('#demo').click(function() {
dialog.dialog('open');
// prevent the default action, e.g., following a link
return false;
});
});
Instead of adding a click handler to a specific id, it's probably better to use a CSS class that indicates authentication is required for a link, or -- even better -- use Ajax to see if the link is secured.
The login page then has the following JavaScript to add a click handler to the "login" button that submits the request securely to the LoginService.
var getHost = function() {
var port = (window.location.port == "8080") ? ":8443" : "";
return ((secure) ? 'https://' : 'http://') + window.location.hostname + port;
};
var loginFailed = function(data, status) {
$(".error").remove();
$('#username-label').before('<div class="error">Login failed, please try again.</div>');
};
$("#login").live('click', function(e) {
e.preventDefault();
$.ajax({url: getHost() + "/api/login.json",
type: "POST",
data: $("#loginForm").serialize(),
success: function(data, status) {
if (data.loggedIn) {
// success
dialog.dialog('close');
location.href= getHost() + '/users';
} else {
loginFailed(data);
}
},
error: loginFailed
});
});
The biggest secret to making this all work (the HTTP -> HTTPS communication, which is considered cross-domain), is the window.name Transport and the jQuery plugin that implements it. To make this plugin work with Firefox 3.6, I had to implement a Filter that adds Access-Control headers. A question on Stackoverflow helped me figure this out.
public class OptionsHeadersFilter implements Filter {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) res;
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET,POST");
response.setHeader("Access-Control-Max-Age", "360");
response.setHeader("Access-Control-Allow-Headers", "x-requested-with");
chain.doFilter(req, res);
}
public void init(FilterConfig filterConfig) {
}
public void destroy() {
}
}
Issues
I encountered a number of issues when implementing this in the ajax-login project.
- If you try to run this with ports (e.g. 8080 and 8443) in your URLs, you'll get a 501 (Not Implemented) response. Removing the ports by fronting with Apache and mod_proxy solves this problem.
- If you haven't accepted the certificate in your browser, the Ajax request will fail. In the example, I solved this by clicking on the "Users" tab to make a secure request, then going back to the homepage to try and login.
- The jQuery window.name version 0.9.1 doesn't work with jQuery 1.5.0. The error is "$.httpSuccess function not found."
- Finally, even though I was able to authenticate successfully, I was unable to make the authentication persist.
I tried adding the following to persist the updated SecurityContext to
the session, but it doesn't work. I expect the solution is to create a
secure JSESSIONID cookie somehow.
@Autowired
SecurityContextRepository repository;
@RequestMapping(method = RequestMethod.POST)
@ResponseBody
public LoginStatus login(@RequestParam("j_username") String username,
@RequestParam("j_password") String password,
HttpServletRequest request, HttpServletResponse response) {
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
...
try {
Authentication auth = authenticationManager.authenticate(token);
SecurityContextHolder.getContext().setAuthentication(auth);
// save the updated context to the session
repository.saveContext(SecurityContextHolder.getContext(), request, response);
return new LoginStatus(auth.isAuthenticated(), auth.getName());
} catch (BadCredentialsException e) {
return new LoginStatus(false, null);
}
}
Conclusion
This article has shown you how to force HTTPS for login, how to do
integration testing with a self-generated certificate, how to implement a
LoginService with Spring MVC and Spring Security, as well as how to use
jQuery to talk to a service cross-domain with the window.name
Transport. While I don't have everything working as much as I'd like, I
hope this helps you implement a similar feature in your applications.
One thing to be aware of is with lightbox/dialog logins and HTTP -> HTTPS is that users won't see a secure icon in their address bar. If your app has sensitive data, you might want to force https for your entire app. OWASP's Secure Login Pages has a lot of good tips in this area.
Update: I've posted a demo of the ajax-login webapp. Thanks to Contegix for hosting the demo and helping obtain/install an SSL certificate so quickly.Update March 24, 2011: Rob Winch figured out how to make this work and sent me a patch. From his comment:
The first issue is that Access-Control-Allow-Credentials header must be set to true. This is so that the browser knows it can send and accept cookies. The second issue is that XMLHttpRequest.withCredentials should be set to true. The last change was that in order to allow credentials to work across domains, the Access-Control-Allow-Origin must be a specific value (i.e. it won't work if you use a wildcard). For more information, you can read about it on mozilla's site.
I've updated the demo with these changes. Works great now - thanks Rob!
From http://raibledesigns.com/rd/entry/implementing_ajax_authentication_using_jquery
Opinions expressed by DZone contributors are their own.
Comments