Matt Raible has been building web applications for most of his adult life. He started tinkering with the web before Netscape 1.0 was even released. For the last 16 years, Matt has helped companies adopt open source technologies (Spring, Hibernate, Apache, Struts, Tapestry, Grails) and use them effectively. Matt has been a speaker at many conferences worldwide, including Devoxx, Jfokus, ÜberConf, No Fluff Just Stuff, and a host of others. Matt is a DZone MVB and is not an employee of DZone and has posted 144 posts at DZone. You can read more from them at their website. View Full User Profile

Implementing Ajax Authentication using jQuery, Spring Security and HTTPS

02.24.2011
| 47309 views |
  • submit to reddit

I'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:

  1. Force HTTPS for authentication.
  2. Allow testing HTTPS without installing a certificate locally.
  3. Implement a RESTful LoginService that allows users to login.
  4. 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.

<artifactId>maven-failsafe-plugin</artifactId>
<version>2.7.2</version>
<configuration>
<includes>
<include>**/*WebTest.java</include>
</includes>
<systemPropertyVariables>
<javax.net.ssl.trustStore>${project.build.directory}/ssl.keystore</javax.net.ssl.trustStore>
<javax.net.ssl.trustStorePassword>appfuse</javax.net.ssl.trustStorePassword>
</systemPropertyVariables>
</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

Published at DZone with permission of Matt Raible, author and DZone MVB.

(Note: Opinions expressed in this article and its replies are the opinions of their respective authors and not those of DZone, Inc.)

Tags:

Comments

David Jones replied on Thu, 2011/02/24 - 6:40pm

Hi Matt, I have recently attempted this myself and managed to persist the authentication. Firstly I don't code a LoginService::login(..) method, instead I allow the security filter to do this and I get the logged in status at a point after the filter. In the code below the filter does its job and then forwards to /login/status which return a JSON status object (as you have). The config is as follows.

<http auto-config='false' use-expressions="true">
   <form-login default-target-url="/login/status"
                      login-processing-url="/login"
                      authentication-failure-url="/login/status" />

I have a controler method:

   @RequestMapping(value = "/login/status",  method = { RequestMethod.GET  })
    public @ResponseBody
    SecurityDetail getLoggedInDetails() { 
        DMUser loggedInUser = SecuritySupport.getLoggedInUser();
        SecurityDetail securityDetail = new SecurityDetail();
        if (loggedInUser != null) {
            securityDetail.setUserName(loggedInUser.getUsername());
        } 

        return securityDetail;
    }

So perhaps the filter is doing something it needs to?

Matt Raible replied on Thu, 2011/02/24 - 10:25pm

David,

Are you communicating HTTP -> HTTPS? My implementation works fine when doing HTTPS -> HTTPS, it just fails when doing x-domain. I created a branch that talks directly to j_security_check and gets a JSON response. It has the same problems that my LoginService does.

https://github.com/mraible/ajax-login/tree/j_security_check

David Jones replied on Thu, 2011/02/24 - 11:46pm in response to: Matt Raible

Hi Matt, I just ran it over HTTP all the way so my code doesn't solve the issue. I can't see why this would happen but I'll feedback if I find anything.

Gal Levinsky replied on Tue, 2012/10/09 - 1:26pm

Great post.
I posted another, more conventional way (utilizing Spring) which overcome some of the issues you discussed.

http://gal-levinsky.blogspot.co.il/2011/08/spring-security-3-ajax-login.html

Comment viewing options

Select your preferred way to display the comments and click "Save settings" to activate your changes.