简体   繁体   中英

ASP.NET Core 2.0 combining Cookies and Bearer Authorization for the same endpoint

I've created a new ASP.NET Core Web Application project in VS17 using the "Web Application (Model-View-Controller)" template and ".Net Framework" + "ASP.NET Core 2" as the configuration. The authentication config is set to "Individual User Accounts".

I have the following sample endpoint:

[Produces("application/json")]
[Route("api/price")]
[Authorize(Roles = "PriceViwer", AuthenticationSchemes = "Cookies,Bearer")]
public class PriceController : Controller
{

    public IActionResult Get()
    {
        return Ok(new Dictionary<string, string> { {"Galleon/Pound",
                                                   "999.999" } );
    }
}

"Cookies,Bearer" is derived by concatenating CookieAuthenticationDefaults.AuthenticationScheme and JwtBearerDefaults.AuthenticationScheme .

The objective is to be able to configure the authorization for the end point so that it's possible access it using both the token and cookie authentication methods.

Here is the setup I have for Authentication in my Startup.cs:

    services.AddAuthentication()
        .AddCookie(cfg => { cfg.SlidingExpiration = true;})
        .AddJwtBearer(cfg => {
            cfg.RequireHttpsMetadata = false;
            cfg.SaveToken = true;
            cfg.TokenValidationParameters = new TokenValidationParameters() {
                                                    ValidIssuer = Configuration["Tokens:Issuer"],
                                                    ValidAudience = Configuration["Tokens:Issuer"],
                                                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Tokens:Key"]))
                                                };
        });

So, when I try to access the endpoint using a browser, I get the 401 response with a blank html page.
我收到带有空白 html 页面的 401 响应。

Then I login and when I try to access the endpoint again, I get the same response.

Then, I try to access the endpoint by specifying the bearer token. And that returns the desired result with the 200 response.
这将返回带有 200 响应的所需结果。

So then, if I remove [Authorize(AuthenticationSchemes = "Cookies,Bearer")] , the situation becomes the opposite - cookie authentication works and returns 200, however the same bearer token method as used above doesn't give any results and just redirect to the default AspIdentity login page.

I can see two possible problems here:

1) ASP.NET Core doesn't allow 'combined' authentication. 2) 'Cookies' is not a valid schema name. But then what is the right one to use?

Please advise. Thank you.

If I understand the question correctly then I believe that there is a solution. In the following example I am using cookie AND bearer authentication in a single app. The [Authorize] attribute can be used without specifying the scheme, and the app will react dynamically, depending on the method of authorization being used.

services.AddAuthentication is called twice to register the 2 authentication schemes. The key to the solution is the call to services.AddAuthorization at the end of the code snippet, which tells ASP.NET to use BOTH schemes.

I've tested this and it seems to work well.

(Based on Microsoft docs .)

services.AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = "oidc";
    })
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddOpenIdConnect("oidc", options =>
    {
        options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.Authority = "https://localhost:4991";
        options.RequireHttpsMetadata = false;

        options.ClientId = "WebApp";
        options.ClientSecret = "secret";

        options.ResponseType = "code id_token";
        options.Scope.Add("api");
        options.SaveTokens = true;
    });

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://localhost:4991";
        options.RequireHttpsMetadata = false;
        // name of the API resource
        options.Audience = "api";
    });

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

EDIT

This works for authenticated users, but simply returns a 401 (unauthorized) if a user has not yet logged in.

To ensure that unauthorized users are redirected to the login page, add the following code to the Configure method in your Startup class. Note: it's essential that the new middleware is placed after the call the app.UseAuthentication() .

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");
    }
});

If you know a cleaner way to achieve this redirect, please post a comment!

I think you don't need to set the AuthenticationScheme to your Controller. Just use Authenticated user in ConfigureServices like this:

// requires: using Microsoft.AspNetCore.Authorization;
//           using Microsoft.AspNetCore.Mvc.Authorization;
services.AddMvc(config =>
{
    var policy = new AuthorizationPolicyBuilder()
                     .RequireAuthenticatedUser()
                     .Build();
    config.Filters.Add(new AuthorizeFilter(policy));
});

For Documentation of my sources: registerAuthorizationHandlers

For the part, whether the scheme-Key wasn't valid, you could use an interpolated string, to use the right keys:

[Authorize(AuthenticationSchemes = $"{CookieAuthenticationDefaults.AuthenticationScheme},{JwtBearerDefaults.AuthenticationScheme}")]

Edit: I did further research and came to following conclusion: 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 like this:

//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();
}

After many hours of research and head-scratching, this is what worked for me in ASP.NET Core 2.2 -> ASP.NET 5.0:

  • Use .AddCookie() and .AddJwtBearer() to configure the schemes
  • Use a custom policy scheme to forward to the correct Authentication Scheme.

You do not need to specify the scheme on each controller action and will work for both. [Authorize] is enough.

services.AddAuthentication( config =>
{
    config.DefaultScheme = "smart";
} )
.AddPolicyScheme( "smart", "Bearer or Jwt", options =>
{
    options.ForwardDefaultSelector = context =>
    {
        var bearerAuth = context.Request.Headers["Authorization"].FirstOrDefault()?.StartsWith( "Bearer " ) ?? false;
        // You could also check for the actual path here if that's your requirement:
        // eg: if (context.HttpContext.Request.Path.StartsWithSegments("/api", StringComparison.InvariantCulture))
        if ( bearerAuth )
            return JwtBearerDefaults.AuthenticationScheme;
        else
            return CookieAuthenticationDefaults.AuthenticationScheme;
    };
} )
.AddCookie( CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
    options.LoginPath = new PathString( "/Account/Login" );
    options.AccessDeniedPath = new PathString( "/Account/Login" );
    options.LogoutPath = new PathString( "/Account/Logout" );
    options.Cookie.Name = "CustomerPortal.Identity";
    options.SlidingExpiration = true;
    options.ExpireTimeSpan = TimeSpan.FromDays( 1 ); //Account.Login overrides this default value
} )
.AddJwtBearer( JwtBearerDefaults.AuthenticationScheme, options =>
{
    options.RequireHttpsMetadata = false;
    options.SaveToken = true;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey( key ),
        ValidateIssuer = false,
        ValidateAudience = false
    };
} );

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

Tested with Asp.net Core 2.2

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

services.AddAuthentication(options =>
    {
        options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(options =>
    {
        options.Authority = "https://localhost:4991";
        options.RequireHttpsMetadata = false;
        // name of the API resource
        options.Audience = "api";
    });


services.AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = "oidc";
    })
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddOpenIdConnect("oidc", options =>
    {
        options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.Authority = "https://localhost:4991";
        options.RequireHttpsMetadata = false;

        options.ClientId = "WebApp";
        options.ClientSecret = "secret";

        options.ResponseType = "code id_token";
        options.Scope.Add("api");
        options.SaveTokens = true;
    });

services.AddAuthorization(options =>
{   
    // Add policies for API scope claims
     options.AddPolicy(AuthorizationConsts.ReadPolicy,
        policy => policy.RequireAssertion(context =>
            context.User.HasClaim(c =>
                ((c.Type == AuthorizationConsts.ScopeClaimType && c.Value == AuthorizationConsts.ReadScope)
                || (c.Type == AuthorizationConsts.IdentityProviderClaimType))) && context.User.Identity.IsAuthenticated
        ));
    // No need to add default policy here
});


app.UseAuthentication();
app.UseCookiePolicy();

In the controller, add necessary Authorize attribute

[Authorize(AuthenticationSchemes = AuthorizationConsts.BearerOrCookiesAuthenticationScheme, Policy = AuthorizationConsts.ReadPolicy)]

Here is the helper class

public class AuthorizationConsts
{
    public const string BearerOrCookiesAuthenticationScheme = CookieAuthenticationDefaults.AuthenticationScheme + "," + IdentityServerAuthenticationDefaults.AuthenticationScheme;
    public const string IdentityProviderClaimType = "idp";
    public const string ScopeClaimType = "scope";
    public const string ReadPolicy = "RequireReadPolicy";
    public const string ReadScope = "data:read";
}

I had a scenario where I need to use Bearer or Cookie only for file download api alone. So following solution works for me.

Configure services as shown below.

services.AddAuthentication(options =>
{
    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddCookie()
.AddJwtBearer(options =>
{
    options.Authority = gatewayUrl;
})
.AddOpenIdConnect(options =>
{
    // Setting default signin scheme for openidconnect makes it to force 
    // use cookies handler for signin 
    // because jwthandler doesnt have SigninAsync implemented
    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.Authority = "https://youridp.com";
    options.ClientId = "yourclientid";
    options.CallbackPath = "/signin-oidc";
    options.ResponseType = OpenIdConnectResponseType.Code;
});

Then configure your controller as shown below.

[HttpGet]
[Authorize(AuthenticationSchemes = "Bearer,OpenIdConnect")]
public async Task<IActionResult> Download([FromQuery(Name = "token")] string token)
{
    ///your code goes here.
    ///My file download api will work with both bearer or automatically authenticate with cookies using OpenidConnect.
}

Christo Carstens, answer worked perfectly for me. Just thought I'd share an additional check that I added to his AddPolicyScheme. (see above) In my case the issue was that I had an Azure Web Service that was handling all my mobile app requests using JWT, but I also needed it to act as a gateway for Google/Apple/Facebook authentication which uses cookies. I updated my startup as recommended

.AddPolicyScheme( "smart", "Bearer or Jwt", options =>
{
    options.ForwardDefaultSelector = context =>
    {
        var bearerAuth = context.Request.Headers["Authorization"].FirstOrDefault()?.StartsWith( "Bearer " ) ?? false;
        // You could also check for the actual path here if that's your requirement:
        // eg: if (context.HttpContext.Request.Path.StartsWithSegments("/api", StringComparison.InvariantCulture))
        if ( bearerAuth )
            return JwtBearerDefaults.AuthenticationScheme;
        else
            return CookieAuthenticationDefaults.AuthenticationScheme;
    };
} )

My only problem was that if a call was made to any of my api calls which had the [Authorize] attribute set, and no "Authorization" key was in the headers, then it would use Cookie authorization and return a Not found (404) instead of Unauthorized (401). His suggestion to check for the Path worked, but I wanted to enforce JWT on any method which, in the future, may not have that path. In the end I settled for this code.

.AddPolicyScheme("CookieOrJWT", "Bearer or Jwt", options =>
                {
                    options.ForwardDefaultSelector = context =>
                    {
                        var bearerAuth = context.Request.Headers["Authorization"].FirstOrDefault()?.StartsWith("Bearer ") ?? false;
                        
                        if (bearerAuth)
                            return JwtBearerDefaults.AuthenticationScheme;
                        else
                        {
                            var ep = context.GetEndpoint();
                            var requiresAuth = ep?.Metadata?.GetMetadata<AuthorizeAttribute>();
                            return requiresAuth != null 
                                ? JwtBearerDefaults.AuthenticationScheme
                            : CookieAuthenticationDefaults.AuthenticationScheme;
                        }
                    };
                })

By checking the Endpoint metadata (only in rare cases where Authorization is not in the header), I can set JwtBearerDefaults.AuthenticationScheme for any method decorated with the [Authorize] attribute. This works even if the method is inheriting the [Authorize] attribute from it's class and does not have it explicitly set. eg

[ApiController]
[Route("api/[Controller]")]
[Authorize]
public class MyController : ControllerBase {
  
    [HttpGet]
    public ActionResult MyWebRequestThatRequiresAuthorization() {
       return true;
    }
}

Thanks to Christo Carstens for the solution. I was breaking my head over this. Saved me countless hours.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM