25
votes

we have a SPA (Angular) with API backend (ASP.NET Core WebAPI):

SPA is listens on app.mydomain.com, API on app.mydomain.com/API

We use JWT for Authentication with built-in Microsoft.AspNetCore.Authentication.JwtBearer; I have a controller app.mydomain.com/API/auth/jwt/login which creates tokens. SPA saves them into local storage. All works perfect. After a security audit, we have been told to switch local storage for cookies.

The problem is, that API on app.mydomain.com/API is used by SPA but also by a mobile app and several customers server-2-server solutions.

So, we have to keep JWT as is, but add Cookies. I found several articles which combines Cookies and JWT on different controllers, but I need them work side-by-side on each controller.

If client sends cookies, authenticate via cookies. If client sends JWT bearer, authenticate via JWT.

Is this achievable via built-in ASP.NET authentication or DIY middleware?

Thanks!

6
Using cookies for your MVC controllers is fine, but I would advice against using cookies for WebAPI because your api becomes vulnerable to Cross Site Request Forgery / XSRF and securing that is a bigger pain in the butt (Anti Request forgery on WebAPI is a bigger pain in the ass than in MVC Apps).Tseng
Well may still be better than cookie and webapi. It could make attackers perform actions with the logged in users permissions if he can lure him to any other side or a hidden form where you have even less control. On top of that, Antiforgery request requires a state (cookie and the correct token on the server to compare with later) , which violates REST-services "stateless" nature. Also issuing new AntiRequest forgery tokens isn't intuitive in SPAs, you'd need to request the server every single time before you send a request to obtain a new token valid for the next requestTseng
Your best bet imho is, using opaque (or reference token in IdentityServer 4 terms) token. Its still required to send the token on every request, but you can enable token validation, so you can rather quickly revoke tokens in case it gets compromised and use. Also you could put the users IP into the token when issued and if the IP changes it gets invalidated. More annoying for the user, but it prevents an attacker from using either the access or refresh tokens himself (unless again, the user can inject javascript code into the application).Tseng
But both approaches (Cookie and JWT) are suspectible to attacks when code is injected. Http Cookie doesnt allow the attacker to steal the cookie, but he can still perform actions on behalf of the logged in user. Same for JWT cookies stored in local storage with the exception that they can also steal the tokens themselves, but this can be prevented by putting IP as a claim into the token and validating it on the server or at least made harder (IP can be spoofed, but the attacker can't get any response). Complex topicTseng
Thanks for your effort. We will re-open the security audit recommendations and will have a brainstorming @work.Luke1988

6 Answers

8
votes

Okay, I have been trying achieving this for a while and i solved same issue of using jwt Authentication Tokens and Cookie Authentication with the following code.

API Service Provider UserController.cs

This Provide Different Services to the User with Both (Cookie and JWT Bearer)Authentication Schemes

[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)] 
[Route("[controller]")]
[ApiController]
public class UsersController : ControllerBase
{ 
    private readonly IUserServices_Api _services;
    public UsersController(IUserServices_Api services)
    {
        this._services = services;
    }
     
    [HttpGet]
    public IEnumerable<User> Getall()
    {
        return _services.GetAll();
    }
}

My Startup.cs

public void ConfigureServices(IServiceCollection services)
    {
          
        services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
         
        services.AddAuthentication(options => {
            options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
        })
            .AddCookie(options =>
            {
                options.LoginPath = "/Account/Login";
                options.AccessDeniedPath = "/Home/Error";
            })
            .AddJwtBearer(options =>
            {
                options.SaveToken = true;
                options.RequireHttpsMetadata = false;
                options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters()
                {
                    ValidateIssuer = true,
                    ValidateAudience = true,
                    ValidAudience = " you site link blah blah",
                    ValidIssuer = "You Site link Blah  blah",
                    IssuerSigningKey = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(sysController.GetSecurityKey()))
                    ,
                    ValidateLifetime = true,
                    ClockSkew = TimeSpan.Zero
                };
            });

    }

And further if you want custom Authentication for a specific Controller then you have to specify the Authentitcation Type for the Authorization like:

[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
public IActionResult Index()
{
    return View();    // This can only be Access when Cookie Authentication is Authorized.
}

[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public IActionResult Index()
{
    return View();    // And this one will be Access when JWT Bearer is Valid
}
7
votes

I've been having the same issue and i just found what it seems to be the solution in another question here in stackoverflow.

Please take a look at this.

I'll try that solution myself and update this answer with the results.

Edit: It seems it's not possible to achieve double authentication types in a same method but the solution provided in the link i mentioned says:

It's not possible to authorize a method with two Schemes Or-Like, but you can use two public methods, to call a private method

//private method
private IActionResult GetThingPrivate()
{
   //your Code here
}
//Jwt-Method
[Authorize(AuthenticationSchemes = $"{JwtBearerDefaults.AuthenticationScheme}")]
[HttpGet("bearer")]
public IActionResult GetByBearer()
{
   return GetThingsPrivate();
}
 //Cookie-Method
[Authorize(AuthenticationSchemes = $"{CookieAuthenticationDefaults.AuthenticationScheme}")]
[HttpGet("cookie")]
public IActionResult GetByCookie()
{
   return GetThingsPrivate();
}    

Anyway you should take a look at the link, it sure helped me. Credit goes to Nikolaus for the answer.

5
votes

I have not been able to find much information on a good way to do this - having to duplicate the API is a pain just to support 2 authorization schemes.

I have been looking into the idea of using a reverse proxy and it looks to me like a good solution for this.

  1. User signs into Website (use cookie httpOnly for session)
  2. Website uses Anti-Forgery token
  3. SPA sends request to website server and includes anti-forgery token in header: https://app.mydomain.com/api/secureResource
  4. Website server verifies anti-forgery token (CSRF)
  5. Website server determines request is for API and should send it to the reverse proxy
  6. Website server gets users access token for API
  7. Reverse proxy forwards request to API: https://api.mydomain.com/api/secureResource

Note that the anti-forgery token (#2,#4) is critical or else you could expose your API to CSRF attacks.


Example (.NET Core 2.1 MVC with IdentityServer4):

To get a working example of this I started with the IdentityServer4 quick start Switching to Hybrid Flow and adding API Access back. This sets up the scenario I was after where a MVC application uses cookies and can request an access_token from the identity server to make calls the API.

I used Microsoft.AspNetCore.Proxy for the reverse proxy and modified the quick start.

MVC Startup.ConfigureServices:

services.AddAntiforgery();
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();

MVC Startup.Configure:

app.MapWhen(IsApiRequest, builder =>
{
    builder.UseAntiforgeryTokens();

    var messageHandler = new BearerTokenRequestHandler(builder.ApplicationServices);
    var proxyOptions = new ProxyOptions
    {
        Scheme = "https",
        Host = "api.mydomain.com",
        Port = "443",
        BackChannelMessageHandler = messageHandler
    };
    builder.RunProxy(proxyOptions);
});

private static bool IsApiRequest(HttpContext httpContext)
{
    return httpContext.Request.Path.Value.StartsWith(@"/api/", StringComparison.OrdinalIgnoreCase);
}

ValidateAntiForgeryToken (Marius Schulz):

public class ValidateAntiForgeryTokenMiddleware
{
    private readonly RequestDelegate next;
    private readonly IAntiforgery antiforgery;

    public ValidateAntiForgeryTokenMiddleware(RequestDelegate next, IAntiforgery antiforgery)
    {
        this.next = next;
        this.antiforgery = antiforgery;
    }

    public async Task Invoke(HttpContext context)
    {
        await antiforgery.ValidateRequestAsync(context);
        await next(context);
    }
}

public static class ApplicationBuilderExtensions
{
    public static IApplicationBuilder UseAntiforgeryTokens(this IApplicationBuilder app)
    {
        return app.UseMiddleware<ValidateAntiForgeryTokenMiddleware>();
    }
}

BearerTokenRequestHandler:

public class BearerTokenRequestHandler : DelegatingHandler
{
    private readonly IServiceProvider serviceProvider;

    public BearerTokenRequestHandler(IServiceProvider serviceProvider, HttpMessageHandler innerHandler = null)
    {
        this.serviceProvider = serviceProvider;
        InnerHandler = innerHandler ?? new HttpClientHandler();
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var httpContextAccessor = serviceProvider.GetService<IHttpContextAccessor>();
        var accessToken = await httpContextAccessor.HttpContext.GetTokenAsync("access_token");
        request.Headers.Authorization =new AuthenticationHeaderValue("Bearer", accessToken);
        var result = await base.SendAsync(request, cancellationToken);
        return result;
    }
}

_Layout.cshtml:

@Html.AntiForgeryToken()

Then using your SPA framework you can make a request. To verify I just did a simple AJAX request:

<a onclick="sendSecureAjaxRequest()">Do Secure AJAX Request</a>
<div id="ajax-content"></div>

<script language="javascript">
function sendSecureAjaxRequest(path) {
    var myRequest = new XMLHttpRequest();
    myRequest.open('GET', '/api/secureResource');
    myRequest.setRequestHeader("RequestVerificationToken",
        document.getElementsByName('__RequestVerificationToken')[0].value);
    myRequest.onreadystatechange = function () {
        if (myRequest.readyState === XMLHttpRequest.DONE) {
            if (myRequest.status === 200) {
                document.getElementById('ajax-content').innerHTML = myRequest.responseText;
            } else {
                alert('There was an error processing the AJAX request: ' + myRequest.status);
            }
        }  
    };
    myRequest.send();
};
</script>

This was a proof of concept test so your mileage may very and I'm pretty new to .NET Core and middleware configuration so it could probably look prettier. I did limited testing with this and only did a GET request to the API and did not use SSL (https).

As expected, if the anti-forgery token is removed from the AJAX request it fails. If the user is has not logged in (authenticated) the request fails.

As always, each project is unique so always verify your security requirements are met. Please take a look at any comments left on this answer for any potential security concerns someone might raise.

On another note, I think once subresource integrity (SRI) and content security policy (CSP) is available on all commonly used browsers (i.e. older browsers are phased out) local storage should be re-evaluated to store API tokens which will lesson the complexity of token storage. SRI and CSP should be used now to help reduce the attack surface for supporting browsers.

4
votes

I think the easiest solution is one proposed by David Kirkland:

Create combined authorization policy (in ConfigureServices(IServiceCollection services)):

services.AddAuthorization(options =>
{
    var defaultAuthorizationPolicyBuilder = new AuthorizationPolicyBuilder(
        CookieAuthenticationDefaults.AuthenticationScheme,
        JwtBearerDefaults.AuthenticationScheme);
    defaultAuthorizationPolicyBuilder =
        defaultAuthorizationPolicyBuilder.RequireAuthenticatedUser();
    options.DefaultPolicy = defaultAuthorizationPolicyBuilder.Build();
});

And add middleware that will redirect to login in case of 401 (in Configure(IApplicationBuilder app)):

app.UseAuthentication();
app.Use(async (context, next) =>
{
    await next();
    var bearerAuth = context.Request.Headers["Authorization"]
        .FirstOrDefault()?.StartsWith("Bearer ") ?? false;
    if (context.Response.StatusCode == 401
        && !context.User.Identity.IsAuthenticated
        && !bearerAuth)
    {
        await context.ChallengeAsync("oidc");
    }
});
1
votes

while looking for combined firebase authorization with net core web api (cookie for web site and authorization header for mobile app ) end with the following solution.

public void ConfigureServices(IServiceCollection services)
        {
            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
               .AddJwtBearer(options =>
               {
                   options.Authority = "https://securetoken.google.com/xxxxx";
                   options.TokenValidationParameters = new TokenValidationParameters
                   {
                       ValidateIssuer = true,
                       ValidIssuer = options.Authority,
                       ValidateAudience = true,
                       ValidAudience = "xxxxx",
                       ValidateLifetime = true
                   };
                   options.Events = new JwtBearerEvents
                   {
                       OnMessageReceived = context =>
                       {
                           if (context.Request.Cookies.ContainsKey(GlobalConst.JwtBearer))
                           {
                               context.Token = context.Request.Cookies[GlobalConst.JwtBearer];
                           }
                           else if (context.Request.Headers.ContainsKey("Authorization"))
                                {
                                    var authhdr = context.Request.Headers["Authorization"].FirstOrDefault(k=>k.StartsWith(GlobalConst.JwtBearer));
                                    if (!string.IsNullOrEmpty(authhdr))
                                    {
                                        var keyval = authhdr.Split(" ");
                                        if (keyval != null && keyval.Length > 1) context.Token = keyval[1];
                                    }
                                }
                           return Task.CompletedTask;
                       }
                   };
               });

where

 public static readonly string JwtBearer = "Bearer";

seems working fine. checked it from mobile & postman (for cookie )

-2
votes

ASP.NET Core 2.0 Web API

Please follow this post for implementing JWT Token based authentication

https://fullstackmark.com/post/13/jwt-authentication-with-aspnet-core-2-web-api-angular-5-net-core-identity-and-facebook-login

If you are using visual studio make sure apply the Bearer type athentication type with the filter

[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]

for controller or actions.