SimpleMembership in MVC4 app + WebApi using basic HTTP auth
Asked Answered
U

3

7

I'm trying to implement an MVC4 web application with the following requirements:

(a) it offers its services to authenticated users only. As for authentication, I'd like to use simple membership, as it is the latest authentication technique from MVC, gives me the advantage of defining my own db tables, provides OAuth support out of the box, and is easily integrated with both MVC and WebApi.

(b) it exposes some core functions via WebApi for mobile/JS clients, which should be authenticated via basic HTTP authentication (+SSL). Typically I'll have JS clients using jQuery AJAX calls to WebApi controllers, decorated with the Authorize attribute for different user roles.

(c) ideally, in a mixed environment I would like to avoid a double authentication: i.e. if the user is already authenticated via browser, and is visiting a page implying a JS call to a WebApi controller action, the (a) mechanism should be enough.

Thus, while (a) is covered by the default MVC template, (b) requires basic HTTP authentication without the mediation of a browser. To this end, I should create a DelegatingHandler like the one I found in this post: http://www.piotrwalat.net/basic-http-authentication-in-asp-net-web-api-using-message-handlers. The problem is that its implementation requires some way of retrieving an IPrincipal from the received user name and password, and the WebSecurity class does not provide any method for this (except Login, but I would avoid changing the logged user just for the purpose of authorization, also because of potential "mixed" environments like (c)). So it seems my only option is giving up simple membership. Does anyone have better suggestions? Here is the relevant (slightly modified) code from the cited post:

public interface IPrincipalProvider
{
    IPrincipal GetPrincipal(string username, string password);
}

public sealed class Credentials
{
    public string Username { get; set; }
    public string Password { get; set; }
}

public class BasicAuthMessageHandler : DelegatingHandler
{
    private const string BasicAuthResponseHeader = "WWW-Authenticate";
    private const string BasicAuthResponseHeaderValue = "Basic";

    public IPrincipalProvider PrincipalProvider { get; private set; }

    public BasicAuthMessageHandler(IPrincipalProvider provider)
    {
        if (provider == null) throw new ArgumentNullException("provider");
        PrincipalProvider = provider;
    }

    private static Credentials ParseAuthorizationHeader(string sHeader)
    {
        string[] credentials = Encoding.ASCII.GetString(
            Convert.FromBase64String(sHeader)).Split(new[] { ':' });

        if (credentials.Length != 2 || string.IsNullOrEmpty(credentials[0]) ||
            String.IsNullOrEmpty(credentials[1])) return null;

        return new Credentials
        {
            Username = credentials[0],
            Password = credentials[1],
        };
    }

    protected override System.Threading.Tasks.Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        AuthenticationHeaderValue authValue = request.Headers.Authorization;
        if (authValue != null && !String.IsNullOrWhiteSpace(authValue.Parameter))
        {
            Credentials parsedCredentials = ParseAuthorizationHeader(authValue.Parameter);
            if (parsedCredentials != null)
            {
                Thread.CurrentPrincipal = PrincipalProvider
                    .GetPrincipal(parsedCredentials.Username, parsedCredentials.Password);
            } 
        } 

        return base.SendAsync(request, cancellationToken)
            .ContinueWith(task =>
            {
                var response = task.Result;
                if (response.StatusCode == HttpStatusCode.Unauthorized
                    && !response.Headers.Contains(BasicAuthResponseHeader))
                {
                    response.Headers.Add(BasicAuthResponseHeader,
                        BasicAuthResponseHeaderValue);
                } 
                return response;
            });
    }
}
Unhurried answered 11/2, 2013 at 19:20 Comment(0)
S
3

Here is another solution that meets all of your requirements. It uses SimpleMemberhsip with a mix of forms authentication and basic authentication in an MVC 4 application. It can also support Authorization, but it is not required by leaving the Role property null.

Sleek answered 14/2, 2013 at 13:53 Comment(1)
Thank you, this seems an even better solution at least for my scenario, because I do not need to completely replace the membership provider with the older one. I'll try this route asap. Meanwhile, thanks to both for your answers!Unhurried
U
3

Thank you, this seems the best available solution at this time! I managed to create a dummy solution from scratch (find it here: http://sdrv.ms/YpkRcf ), and it seems to work in the following cases:

1) when I try to access an MVC controller restricted action, I am redirected to the login page as expected to get authenticated.

2) when I trigger a jQuery ajax call to a WebApi controller restricted action, the call succeeds (except of course when not using SSL).

Yet, it does not work when after logging in in the website, the API call still requires authentication. Could anyone explain what's going here? In what follows I detail my procedure as I think it might be useful for starters like me.

Thank you (sorry for the formatting of what follows, but I cannot manage to let this editor mark code appropriately...)


Procedure

  1. create a new mvc4 app (basic mvc4 app: this already comes with universal providers. All the universal providers class names start with Default...);

  2. customize web.config for your non-local DB, e.g.:

      <connectionStrings>
    <add name="DefaultConnection"
     providerName="System.Data.SqlClient"
     connectionString="data source=(local)\SQLExpress;Initial Catalog=Test;Integrated Security=True;MultipleActiveResultSets=True" />
    

Also it's often useful to set a machineKey for hashing passwords, so that you can freely move this site around from server to server without having your passwords scrambled. Use a machine key generator website to define an entry like this:

  <system.web>
        <machineKey
   validationKey="...thekey..."
   decryptionKey="...thekey..."
   validation="SHA1"
   decryption="AES" />
  1. if required create a new, empty database corresponding to the connection string of your web.config. Then start our good old pal WSAT (from VS Project menu) and configure security by adding users and roles as required.

  2. if you want to, add a HomeController with an Index action, because no controller is present in this template and thus you could not test-start your web app without it.

  3. add Thinktecture.IdentityModel.45 from NuGet and add/update all your favorite NuGet packages. Notice that at the time of writing this, jquery validation unobtrusive from MS is no more compatible with jQuery 1.9 or higher. I rather use http://plugins.jquery.com/winf.unobtrusive-ajax/ . So, remove jquery.unobtrusive* and add this library (which consists of winf.unobtrusive-ajax and additional-methods) in your bundles (App_Start/BundleConfig.cs).

  4. modify the WebApiConfig.cs in App_Start by adding it the code after the DefaultApi route configuration:

    public static class WebApiConfig { public static void Register(HttpConfiguration config) { config.Routes.MapHttpRoute( name: "DefaultApi", routeTemplate: "api/{controller}/{id}", defaults: new { id = RouteParameter.Optional } );

        // added for Thinktecture
        var authConfig = new AuthenticationConfiguration
        {
            InheritHostClientIdentity = true,
            ClaimsAuthenticationManager = FederatedAuthentication.FederationConfiguration.IdentityConfiguration.ClaimsAuthenticationManager
        };
    
        // setup authentication against membership
        authConfig.AddBasicAuthentication((userName, password) => Membership.ValidateUser(userName, password));
    
        config.MessageHandlers.Add(new AuthenticationHandler(authConfig));
    }
    

    }

To be cleaner, the api controllers will be placed under Controllers/Api/, so create this folder.

  1. Add to models a LoginModel.cs:

    public class LoginModel { [Required] [Display(Name = "UserName", ResourceType = typeof(StringResources))] public string UserName { get; set; }

    [Required]
    [DataType(DataType.Password)]
    [Display(Name = "Password", ResourceType = typeof(StringResources))]
    public string Password { get; set; }
    
    [Display(Name = "RememberMe", ResourceType = typeof(StringResources))]
    public bool RememberMe { get; set; }
    

    }

This model requires a StringResources.resx resource (with code generation) I usually place under an Assets folder, with the 3 strings quoted in the attributes.

  1. Add a ClaimsTransformer.cs to your solution root, like this:

    public class ClaimsTransformer : ClaimsAuthenticationManager { public override ClaimsPrincipal Authenticate(string resourceName, ClaimsPrincipal incomingPrincipal) { if (!incomingPrincipal.Identity.IsAuthenticated) { return base.Authenticate(resourceName, incomingPrincipal); }

        var name = incomingPrincipal.Identity.Name;
    
        return Principal.Create(
            "Custom", 
            new Claim(ClaimTypes.Name, name + " (transformed)"));
    }
    

    }

  2. Add Application_PostAuthenticateRequest to Global.asax.cs:

    public class MvcApplication : HttpApplication { ... protected void Application_PostAuthenticateRequest() { if (ClaimsPrincipal.Current.Identity.IsAuthenticated) { var transformer = FederatedAuthentication.FederationConfiguration.IdentityConfiguration.ClaimsAuthenticationManager; var newPrincipal = transformer.Authenticate(string.Empty, ClaimsPrincipal.Current);

            Thread.CurrentPrincipal = newPrincipal;
            HttpContext.Current.User = newPrincipal;
        }
    }
    

    }

  3. web.config (replace YourAppNamespace with your app root namespace):

    <configSections> <section name="system.identityModel" type="System.IdentityModel.Configuration.SystemIdentityModelSection, System.IdentityModel, Version=4.0.0.0, Culture=neutral, PublicKeyToken=B77A5C561934E089" /> ...

  4. add the other models for account controller, with their views (you can derive them from MVC3 application template, even if I prefer changing them to more localizable-friendly variants using attributes requiring string resource names rather than literals).

  5. to test browser-based authentication, add some [Authorized] action to a controller (e.g. HomeController), and try accessing it.

  6. to test basic HTTP authentication, insert in some view (e.g. Home/Index) a code like this (set your user name and password in the token variable):

    ...

    <p>Test call

    $(function() { $("#test").click(function () { var token = "USERNAME:PASSWORD"; var hash = $.base64.encode(token); var header = "Basic " + hash; console.log(header);
            $.ajax({
                url: "/api/dummy",
                dataType: "json",
                beforeSend: function(xhr) {
                    xhr.setRequestHeader("Authorization", header);
                },
                success: function(data) {
                    alert(data);
                },
                error: function(jqXHR, textStatus, errorThrown) {
                    alert(errorThrown);
                }
            });
        });
    });
    

This requires the jQuery plugin for encoding/decoding Base64: jquery.base64.js and its minified counterpart.

To allow SSL, follow the instructions here: http://www.hanselman.com/blog/WorkingWithSSLAtDevelopmentTimeIsEasierWithIISExpress.aspx (basically, enable SSL in the web project properties and connect to the port specified in the property value).

Unhurried answered 12/2, 2013 at 23:31 Comment(1)
I must add that I had to modify the ClaimsTransformer code according to the suggestions found at #12832739, brockallen.com/2012/07/08/mvc-4-antiforgerytoken-and-claims and feetens.wordpress.com/2012/06/06/a-slew-of-problems, otherwise I get errors when using AntiForgeryToken's: return Principal.Create("Custom", new Claim(ClaimTypes.Name, name), new Claim(ClaimTypes.NameIdentifier, name));.Unhurried
S
3

Here is another solution that meets all of your requirements. It uses SimpleMemberhsip with a mix of forms authentication and basic authentication in an MVC 4 application. It can also support Authorization, but it is not required by leaving the Role property null.

Sleek answered 14/2, 2013 at 13:53 Comment(1)
Thank you, this seems an even better solution at least for my scenario, because I do not need to completely replace the membership provider with the older one. I'll try this route asap. Meanwhile, thanks to both for your answers!Unhurried

© 2022 - 2024 — McMap. All rights reserved.