jquery-mobilespring-securityspring-web

Why are some URLs "forbidden" and some not in my web app


I have a web application that is experiencing weird behavior. When you try to bring up the app, it requests that you log in as expected, and takes you to the welcome page (/) you can then select either the profile (/profile) page or the search page (/search). If you try to access any of these pages without logging in, it redirects you to the login page as expected. When you try to submit search criteria or submit a password change, however, a 403 Forbidden is returned.

<security:http use-expressions="true">
    <security:intercept-url pattern="/resources/css/*" access="permitAll"  />
    <security:intercept-url pattern="/resources/images/*" access="permitAll"  />
    <security:intercept-url pattern="/login" access="permitAll"  />
    <security:intercept-url pattern="/logout" access="permitAll"  />
    <security:intercept-url pattern="/accessdenied" access="permitAll"  />
    <security:intercept-url pattern="/**" access="hasRole('ROLE_USER')"  />
    <security:form-login 
        login-page="/login" 
        default-target-url="/" 
        authentication-success-handler-ref="loginSuccessHandler" 
        authentication-failure-url="/accessdenied"
    />
    <security:logout 
        logout-success-url="/" 
        logout-url="/perform_logout"
        delete-cookies="JSESSIONID"
    />
</security:http>

urls:

/                       (Welcome Page [GET])
/search                 (Search Page [GET])
/search/data            (Search Query [POST])
/profile                (Profile Page [GET])
/profile/updatePassword (Profile Update [POST])

Profile Controller

@Controller
@RequestMapping({ "/profile" }) 
public class ProfileController {
    @Autowired
    UserService userService = null;
    @Autowired
    ProfileService profileService = null;

    @RequestMapping(value = { "/", "" }, method = RequestMethod.GET)
    public String getProfile(Model model) {
        Profile profile = profileService.getProfile();
        model.addAttribute("profile", profile);
        return "profile";
    }

    @RequestMapping(value = { "/updatePassword" }, method = RequestMethod.POST)
    public @ResponseBody AjaxResponse updatePassword(@RequestBody Profile profile) {
        // do stuff
        return new AjaxResponse(response, null, errors);
    }
}

Search Controller

@Controller
@RequestMapping({ "/search" }) 
public class StockKeepingUnitController {
    @Autowired(required = true)
    private SkuService skuService;
    @Autowired(required = true)
    private UserService userService;

    @RequestMapping(value = {"", "/"}, method = RequestMethod.GET)
    public String search() {
        return "search";
    }

    @RequestMapping(value = "/data", method = RequestMethod.POST)
    public @ResponseBody AjaxResponse data(@RequestBody SearchCriteria searchCriteria) {
        List<StockKeepingUnit> skus = null;
        try {
            String criteria = searchCriteria.getCriteria();
            skus = skuService.listSkusBySearch(criteria);
        } catch (Exception ex) {
            ex.printStackTrace();
            List<String> errors = new ArrayList<>();
            errors.add("Error saving ALOT.");
            return new AjaxResponse("ERROR", null, errors);
        }
        return new AjaxResponse("OK", skus, null);
    }
}

Search ajax

    $.ajax({url: "${pageContext.request.contextPath}/search/data"
        , method: "POST"
        , contentType: "application/json; charset=utf-8"
        , dataType: "json"
        , data: JSON.stringify(searchCriteria)
        , success: function(ajaxResponse) { /* ... */ }
        , error: function(xhr, status, error) { /* ... */ }
    });

Profile ajax

$.ajax({
    url: "${pageContext.request.contextPath}/profile/updatePassword",
    , method: "POST"
    , contentType: "application/json; charset=utf-8"
    , dataType: "json"
    , data: JSON.stringify(profile)
    , success : function(ajaxResponse) { /* ... */ }
    , error : function(xhr, status, error) { /* ... */ }
});

---EDIT--- jQuery for csrf

$(function() {
    var token = $("meta[name='_csrf']").attr("content");
    var header = $("meta[name='_csrf_header']").attr("content");
    $(document).ajaxSend(function(e, xhr, options) {
        xhr.setRequestHeader(header, token);
    });
});

Also I just found out that if I reload each page, the POST submission works. Is there some way the CSRF token changes on each page? I am using jQuery Mobile btw.


Solution

  • The problem is caused because jQuery Mobile doesn't normally load header information on each page request, which is where the CSRF token is stored. So when navigating to a new page, it is using a stale CSRF token when doing a POST, which causes the 403 Forbidden. To overcome this, I forced JQM to link without ajax by including data-ajax="false" in each link to a page. For example:

    <%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
    <form action="<c:url value="/perform_logout" />" method="POST" name="logoutform">
        <input type="hidden" name="${_csrf.parameterName}" value = "${_csrf.token}" />          
    </form>
    <ul data-role="listview" data-theme="a" data-divider-theme="a" style="margin-top: -16px;" class="nav-search">
        <li data-icon="delete" style="background-color: #111;"><a href="#" data-rel="close">Close menu</a></li>
        <li><a href="${pageContext.request.contextPath}/search" data-ajax="false">Search</a></li>
        <li><a href="${pageContext.request.contextPath}/profile" data-ajax="false">Profile</a></li>
        <li><a href="#" onclick="document.logoutform.submit();">Logout</a></li>
    </ul>