受欢迎的博客标签

ASP.NET Core Web API Authentication series:Secure an ASP.NET Core Web Api using Cookies

Published

There’s this frequent notion that you need to use tokens to secure a web api and you can’t use cookies.

That’s not the case. You can do authentication and authorization in a Web Api using cookies the same way you would for a normal web application, and doing so has the added advantage that cookies are easier to setup than for example JWT tokens. There are just a few things you need to be aware.

This blog post is about how you can secure an ASP.NET Core Web Api using cookies (if you are looking for how to secure a Web Api using JWT tokens check out Secure a Web Api in ASP.NET Core and Refresh Tokens in ASP.NET Core Web Api).

 

Configuration required to make cookies work in a Web Api

If one of the clients of your Web Api is a web application (e.g. an Angular app)
and the Web Api and the Angular application are running in different domains (most common scenario) using cookies will not work without some extra configuration.

This might be the reason why using JWT tokens seems to be what people default to. If you try to setup your authentication the same way you would a traditional web application (e.g. an ASP.NET MVC web app) and then perform AJAX requests for logging in and out you’ll soon discover that they seem to do nothing.

For example, when you try to login to your web api using jQuery:

$.post('https://yourdomain.com/api/account/login', "username=theUsername&password=thePassword")

Your response won’t show any error. If you inspect the response it will even have the Set-Cookie header but the cookie will seemingly be ignored by the browser.

To add to the confusion you might even have CORS configured correctly for your Web Api and still see this behavior.

Turns out that you have to do some work in the client as well. The next sections describe what you need to do, both in terms of the server configuration and also the client.

You can find an example project here that is nothing more than the ASP.NET default template with authentication set to Individual User accounts stripped out of all the UI and adapted to be consumed as a Web Api. The sample project also contains an Angular application that consumes the Web Api.

Server side configuration

What you need to do server-side is to configure ASP.NET’s cookie authentication middleware and also setup CORS so that your Web Api “declares” it accepts requests from the domain where your client is hosted.

To setup the cookie middleware you have to setup the authentication middleware in your Startup.csConfigurateServices method:

public void ConfigureServices(IServiceCollection services)
{
    //...
    services.AddAuthentication(options => { 
        options.DefaultScheme = "Cookies"; 
    }).AddCookie("Cookies", options => {
        options.Cookie.Name = "auth_cookie";
        options.Cookie.SameSite = SameSiteMode.None;
        options.Events = new CookieAuthenticationEvents
        {                          
            OnRedirectToLogin = redirectContext =>
            {
                redirectContext.HttpContext.Response.StatusCode = 401;
                return Task.CompletedTask;
            }
        };                
    });

Here I’m naming the cookie authentication scheme as “Cookies” (that’s AddCookie‘s first parameters). We’ll have to reference this name later when implementing the login endpoint.

I’m also naming the cookie that will be created as auth_cookie (options.Cookie.Name = "auth_cookie"). If the consumer of your Web Api is a web client (for example an Angular application) you don’t have to deal with the name of the cookie. However, if you are writing a C# client using HttpClient you might need to read and store the value of the cookie manually. Having an explicit name is easier to remember than the default name, which is .AspNet. + authentication scheme name (in this case that would be .AspNet.Cookies).

Regarding SameSiteMode I’m setting it to None. SameSite is used when setting the Cookie (it controls an attribute with the same name in the Set-Cookie header). It’s values are Strict and Lax. Strict means that the cookie will only be sent by the browser for requests that originate from the domain of the cookie. With this value the browser won’t even send the cookie if you have a website that has a link to yours.

With Lax the browser will send the cookie for requests that originate in the cookie’s domain and cross-origin requests that don’t have side effects (i.e. will be sent with a GET but not with a POST). A cross-origin request is a request that is sent from a url different than the destination url (for most browsers even having different ports, e.g.: localhost:8080 to localhost:8081) will make a request be considered cross-origin.

If you set it to SameSiteMode.None as we did, the samesite attribute isn’t included. That has the consequence of the browser sending the cookie along for all requests, which is what we want.

As an aside, if you need to debug problems with cookies prefer Firefox’s developer tools to Chrome’s. Chrome will not show you the Set-Cookie header if it’s not for the domain where the request originated (checked version 67.0.3396.99).

Finally, I’m redefining what happens when the authentication fails. Usually the cookie middleware produces a 302 redirect response to a login page. Since we are building a Web Api we want to send the client a 401 Unauthorized response instead. That’s what the custom OnRedirectToLogin will do.

When using ASP.NET Core Identity (which is what the demo project uses) this configuration is a little bit different. You won’t have to worry about naming the cookie authentication scheme since ASP.NET Core Identity provides a default value. Also, the redefinition of what happens on the OnRedirectToLogin is a little bit different (but similar enough that it shouldn’t be a problem to understand after seeing this one).

That’s it for the authentication middleware, but still on the ConfigureServices method we also need to add CORS. Just add this line:

services.AddCors();

Finally, in Startup.csConfigure method add the authentication and CORS middleware to the pipeline (before the MVC pipeline):

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    //...
    app.UseCors(policy =>
    {
        policy.AllowAnyHeader();
        policy.AllowAnyMethod();
        policy.AllowAnyOrigin();
        policy.AllowCredentials();
    });

    app.UseAuthentication();
    //...
    app.UseMvc();

Our CORS configuration does not put any restriction on the potential clients of the Web Api. Of particular importance here is the AllowCredentials option. Without it, the browser will ignore the response to any requests that are sent with Cookies (see the Access-Control-Allow-Credentials section in MDN’s documentation on CORS).

Login and Logout actions

The Login and Logout actions are similar to what you would have for a normal MVC application. The only difference here is that we won’t return any content in the responses, just responses with the appropriate status code.

Here’s an example of how the Login method could look like:

[HttpPost]
public async Task<IActionResult> Login(string username, string password)
{
    if (!IsValidUsernameAndPasswod(username, password))
        return BadRequest();

    var user = GetUserFromUsername(username);

    var claimsIdentity = new ClaimsIdentity(new[]
    {
        new Claim(ClaimTypes.Name, user.Username),
        //...
    }, "Cookies");

    var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
    await Request.HttpContext.SignInAsync("Cookies", claimsPrincipal);

    return NoContent();
}

Notice that we are referencing the “Cookies” authentication scheme we’ve defined in Startup.cs.

The Logout method could look like this:

[HttpPost]
public async Task<IActionResult> Logout()
{
    await HttpContext.SignOutAsync();
    return NoContent();
}

In the demo project we rely on ASP.NET Core Identity which provides the UserManager and SignInManager classes that provide identical functionality of what is described above.

The Client

When consuming a Web Api that uses cookies using a browser client you need to be aware of some quirks. Namely of the behavior of XMLHttpRequest and the Fetch Api. If your client is not running in a browser (e.g. a C# application), apart from having to know how to save/restore cookies, there are no hurdles.

There are two ways to perform AJAX requests in the browser. Using XMLHttpRequest or using the Fetch Api. Even if you are using some library or framework (e.g. jQuery or Angular) it’s one of these two that is being used.

When you perform a request using any of these options and the response contains a Set-Cookie header it will be ignored silently. And the documentation on this is not very clear, for example in XMLHttpRequest’s MDN documentation:

XMLHttpRequest.withCredentials

Is a Boolean that indicates whether or not cross-site Access-Control requests should be made using credentials such as cookies or authorization headers.

withCredentials is the flag you need to set to true so that cookies aren’t ignored when they are set by a response (Set-Cookie header) and it is also the flag that you need to have so that cookies are sent in requests.

If you dig into the MDN documentation this is described this way:

In addition, this flag is also used to indicate when cookies are to be ignored in the response … XMLHttpRequest from a different domain cannot set cookie values for their own domain unless withCredentials is set to true before making the request…

There you go, that flag serves two different purposes. Reminds me of this tweet:

 

Since it’s most likely you will not be making requests with XMLHttpRequest manually I’ll abstain from including an example for it here and include instead one for jQuery, another for Angular and another with the Fetch Api.

JQuery

For jQuery you can perform a request with withCredentials set to true this way:

$.ajax({
    url: 'http://yourdomain.com/api/account/login?username=theUsername&password=thePassword', 
    method: 'POST', 
    xhrFields: {
        withCredentials: true
    }
});

Every request needs to have the withCredentials flag.

Doing this with with $.ajax can get tedious fast. Thankfully you can just use $.ajaxSetup and set it there:

$.ajaxSetup({xhrFields: {withCredentials: true}});

Now every subsequent request you perform with jQuery ($.get, $.post, etc) will be done with the withCredentials flag set to true.

Angular

With Angular you can specify options on each call using HttpClient from @angular/common/http, for example:

this.httpClient.post<any>(`http://yourdomain.com/api/account/login?username=theUsername&password=thePassword`, {}, {
  withCredentials: true 
}).subscribe(....

Or, more conveniently, you can create an HttpInterceptor that will add that option to every request for you:

import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpEvent, HttpRequest, HttpHandler } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class AddWithCredentialsInterceptorService implements HttpInterceptor {
    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        return next.handle(req.clone({
            withCredentials: true
        }));
    }
}

Fetch Api

If you are using the more modern Fetch API you need to add the property credentials with value include with every request that might result in a response that creates cookies or requests for which cookie are to be sent with.

Here’s an example of a post request:

fetch('http://yourdomain.com/api/account/login?username=theUsername&password=thePassword', {
    method: 'POST',
    credentials: 'include'
}).then...

.Net Client

To create a .Net Client you’d use HttpClient.

HttpClient will take care of storing the cookie when a response sends it and it will send it for your when you perform a request. You just need to keep the HttpClient instance after you log in, which is the recommended way of doing it anyway.

Here’s how you could login and then perform an authenticated request:

var client = new HttpClient();
var loginResponse = await client.PostAsync("http://yourdomain.com/api/account/login?username=theUsername&password=thePassword", null);
if (!loginResponse.IsSuccessStatusCode){
    //handle unsuccessful login
}                        

var response = await client.GetAsync("http://yourdomain.com/api/anEndpointThatRequiresASignedInUser/");

One thing you might want to do is to save the authentication cookie and restore it later.

Imagine a scenario where your user closes your application and you want to support the user being able to return later an not having to log in.

It is possible to do this with HttpClient, but you need to initialize it a little differently:

CookieContainer cookieContainer = new CookieContainer();
HttpClientHandler handler = new HttpClientHandler
{
    CookieContainer = cookieContainer
};
handler.CookieContainer = cookieContainer;
var client = new HttpClient(handler);

var loginResponse = await client.PostAsync("http://yourdomain.com/api/account/login?username=theUsername&password=thePassword", null);
if (!loginResponse.IsSuccessStatusCode){
    //handle unsuccessful login
}

var authCookie = cookieContainer.GetCookies(new Uri("http://yourdomain.com")).Cast<Cookie>().Single(cookie => cookie.Name == "auth_cookie");

//Save authCookie.ToString() somewhere
//authCookie.ToString() -> auth_cookie=CfDJ8J0_eoL4pK5Hq8bJZ8e1XIXFsDk7xDzvER3g70....

To restore a cookie after creating the CookieContainer you can call the SetCookies method on it:

cookieContainer.SetCookies(new Uri("http://yourdomain.com"), "auth_cookie=CfDJ8J0_eoL4pK5Hq8bJZ8e1XIXFsDk7xDzvER3g70...");

Conclusion

Event though this is a long post, setting up cookies in you Web Api is not that hard. You just need to keep a few things in mind.

Namely, you need to make sure that your cookie is not being generated with a samesite attribute. To do this you should check the Set-Header header that comes in the login response.

Do this using Firefox instead of Chrome’s developer tools. Chrome (at least the version I’m running 67.0.3396.99) does not display the Set-Cookie header if it’s for a different domain than the one from where the request was performed.

The next thing you have to make sure is that you’ve configured CORS correctly. Of particular importance is making sure you have AllowsCredentials in your CORS policy.

Finally, if you are running a web client, make sure you have the withCredentials flag set to true on every request or credentials: 'include' for the Fetch Api.