Search code examples
ajaxforms-authenticationnancy

Forms validation in Nancy not working with AJAX login requests


I'm trying to implement an extremely simple spike using Nancy as an alternative to ASP.NET MVC.

It should take a username (no password) and provide meaningful error messages on the same login page without requiring a refresh. If login was successful, the response includes the URL to navigate to.

The POCO for the response looks like this:

public class LoginResponseModel
{
    public bool IsSuccess { get; set; }
    public string RedirectUrl { get; set; }
    public string ErrorMessage { get; set; }
}

The JS handler for the login request:

$.ajax({
        url: '/login',
        type: "POST",
        data: { UserName: username }
    }).done(function (response) {
        if (response.IsSuccess) {
            showSuccess();
            document.location.href = response.RedirectUrl;
            return;
        }
        showError(response.ErrorMessage);
    }).fail(function (msg) {
        showError("Unable to process login request: " + msg.statusText);
    });

The problem I'm having is with Nancy's Forms-based authentication. I've walked through half a dozen different tutorials which all more or less do the same thing, as well as gone over the Nancy authentication demos. The one thing they all have in common is that they rely on the LoginAndRedirect extension method. I don't want to return a redirect. I want to return a result of the login attempt and let the client handle the navigation.

The IUserMapper implementation I'm using:

public class UserMapper : IUserMapper
{
     public IUserIdentity GetUserFromIdentifier(Guid identifier, NancyContext context)
     {
        // Don't care who at this point, just want ANY user...
        return AuthenticatedUser {UserName = "admin"};
     }
}

The relevant part of my LoginModule action:

var result = _userMapper.ValidateUser(input.AccessCode);

if (result.Guid != null) this.Login(UserMapper.GUID_ADMIN, expiry);

return Response.AsJson(result.Response);

but for subsequent requests Context.CurrentUser is always null.

If I add the following method to the Nancy.Demo.Authentication.Forms sample it reproduces the behaviour I'm seeing in my own project, leading me to believe LoginWithoutRedirect doesn't work how I expected.

Get["/login/{name}"] = x =>
{
    Guid? userGuid = UserDatabase.ValidateUser(x.Name, "password");
    this.LoginWithoutRedirect(userGuid.Value, DateTime.Now.AddYears(2));
    return "Logged in as " + x.Name + " now <a href='~/secure'>see if it worked</a>";
};

Solution

  • The problem turns out to be that Context.CurrentUser with FormsAuthentication is dependent upon a cookie which isn't set if you don't return the NancyModule.Login() response.

    var result = _userMapper.ValidateUser(input.AccessCode);             
    if (result.IsSuccess) {
         this.LoginWithoutRedirect(result.Guid);
    }
    return Response.AsJson(result);
    

    In this example, the LoginWithoutRedirect call returns a Response object with the cookie set. To handle this in an Ajax scenario I've had to add a AuthToken property to the LoginAjaxResponse class, then pass the cookie like so:

    var result = _userMapper.ValidateUser(input.AccessCode);             
    var response = Response.AsJson(result);
    if (result.IsSuccess) {
         var authResult = this.LoginWithoutRedirect(result.Guid);
         result.AuthToken = authResult.Cookies[0].Value;
    }
    return Response.AsJson(result);
    

    On the client, the Ajax response handler changes to (assuming use of jQuery cookie plugin:

    $.ajax({
        url: '/login',
        type: "POST",
        data: { UserName: username }
        }).done(function (response) {
        if (response.IsSuccess) {
            showSuccess();
            $.cookie("_ncfa", response.AuthToken); // <-- the magic happens here
            document.location.href = response.RedirectUrl;
            return;
        }
        showError(response.ErrorMessage);
    }).fail(function (msg) {
       showError("Unable to process login request: " + msg.statusText);
    });
    

    The AuthToken is the GUID which has been encrypted and base64-encoded. Subsequent requests with this.RequiresAuthentication() enabled will first check for this auth token cookie.

    • If no "_ncfa" cookie is present,the UserMapper's GetUserFromIdentifier() is never called.

    • If the value in Context.Request.Cookies["_ncfa"] does not result in a valid GUID when base64-decoded and decrypted, GetUserFromIdentifier() is never called.

    • If GetUserFromIdentifier() isn't called, Context.CurrentUser is never set.

    If you want the source for a working example it's on GitHub.