使用 ASP.NET Core Web Api 验证第三方 Cookie

Validate 3rd Party Cookies with ASP.NET Core Web Api

我正在使用 ORY Kratos 作为身份,我的前端 SPA(React 应用程序)正在针对 Kratos 登录服务器进行身份验证并取回 session cookie。

现在我想以某种方式保护我的 ASP.NET Core Web Api,即用户在将有效的 cookie 附加到时只能调用某些受 [Authorize] 属性保护的方法要求。为此,我需要验证来自每个传入请求的 cookie。所以我正在寻找一种方法来配置身份验证并添加自定义逻辑来验证 cookie(我需要 API 调用 Kratos 来验证它)。

我要验证的 cookie 尚未由要验证它的 ASP.NET 核心应用程序发出。

目前我发现的所有样本也在同一台服务器上发布 cookie,但我需要验证一个外部服务器。

这是我的 cookie 的样子:

在 Dev Tools 中,我可以验证 Cookie 已附加到请求 header:

这是我到目前为止尝试过的:

public void ConfigureServices(IServiceCollection services)
{
    // ...

    services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
      .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => 
      {
          options.Cookie.Name = "ory_kratos_session";
          options.Cookie.Path = "/";
          options.Cookie.Domain = "localhost";
          options.Cookie.HttpOnly = true;
          options.EventsType = typeof(CustomCookieAuthenticationEvents);
      });
    services.AddScoped<CustomCookieAuthenticationEvents>();

    // ...
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // ...

    app.UseAuthentication();
    app.UseAuthorization();

    // ...
}
public class CustomCookieAuthenticationEvents : CookieAuthenticationEvents
{
    public CustomCookieAuthenticationEvents() {}

    public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
    {
        // Never gets called
    }
}

日志:

info: Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler[7]
      Cookies was not authenticated. Failure message: Unprotect ticket failed
info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]
      Authorization failed. These requirements were not met:
      DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
info: Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler[12]
      AuthenticationScheme: Cookies was challenged.
dbug: Microsoft.AspNetCore.Server.Kestrel[9]
      Connection id "0HM6IBAO4PLLL" completed keep alive response.
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 GET https://localhost:5001/weatherforecast - - - 302 0 - 75.3183ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET https://localhost:5001/Account/Login?ReturnUrl=%2Fweatherforecast - -

根据 cookie 身份验证处理程序的 source codes,我发现它会在转到 CustomCookieAuthenticationEvents 之前读取 cookie。

部分代码如下:

    private async Task<AuthenticateResult> ReadCookieTicket()
    {
        var cookie = Options.CookieManager.GetRequestCookie(Context, Options.Cookie.Name!);
        if (string.IsNullOrEmpty(cookie))
        {
            return AuthenticateResult.NoResult();
        }

        var ticket = Options.TicketDataFormat.Unprotect(cookie, GetTlsTokenBinding());
        if (ticket == null)
        {
            return AuthenticateResult.Fail("Unprotect ticket failed");
        }

        if (Options.SessionStore != null)
        {
            var claim = ticket.Principal.Claims.FirstOrDefault(c => c.Type.Equals(SessionIdClaim));
            if (claim == null)
            {
                return AuthenticateResult.Fail("SessionId missing");
            }
            // Only store _sessionKey if it matches an existing session. Otherwise we'll create a new one.
            ticket = await Options.SessionStore.RetrieveAsync(claim.Value);
            if (ticket == null)
            {
                return AuthenticateResult.Fail("Identity missing in session store");
            }
            _sessionKey = claim.Value;
        }

        var currentUtc = Clock.UtcNow;
        var expiresUtc = ticket.Properties.ExpiresUtc;

        if (expiresUtc != null && expiresUtc.Value < currentUtc)
        {
            if (Options.SessionStore != null)
            {
                await Options.SessionStore.RemoveAsync(_sessionKey!);
            }
            return AuthenticateResult.Fail("Ticket expired");
        }

        CheckForRefresh(ticket);

        // Finally we have a valid ticket
        return AuthenticateResult.Success(ticket);
    }

如果您仍想使用cookie 身份验证,则需要重写处理程序。所以我建议你可以像下面这样写一个自定义的 AuthenticationHandler 和 AuthenticationSchemeOptions class 并直接在 startup.cs 中注册 class 。然后你可以使用 [Authorize(AuthenticationSchemes = "Test")] 来设置特殊的 AuthenticationSchemes。

代码:

public class ValidateHashAuthenticationSchemeOptions : AuthenticationSchemeOptions
{

}

public class ValidateHashAuthenticationHandler
: AuthenticationHandler<ValidateHashAuthenticationSchemeOptions>
{
    public ValidateHashAuthenticationHandler(
        IOptionsMonitor<ValidateHashAuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ISystemClock clock)
        : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        //TokenModel model;

        // validation comes in here
        if (!Request.Headers.ContainsKey("X-Base-Token"))
        {
            return Task.FromResult(AuthenticateResult.Fail("Header Not Found."));
        }

        var token = Request.Headers["X-Base-Token"].ToString();

        try
        {
            // convert the input string into byte stream
            using (MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(token)))
            {
                // deserialize stream into token model object
                //model = Serializer.Deserialize<TokenModel>(stream);
            }
        }
        catch (System.Exception ex)
        {
            Console.WriteLine("Exception Occured while Deserializing: " + ex);
            return Task.FromResult(AuthenticateResult.Fail("TokenParseException"));
        }

        //if (model != null)
        //{
        //    // success case AuthenticationTicket generation
        //    // happens from here

        //    // create claims array from the model
        //    var claims = new[] {
        //        new Claim(ClaimTypes.NameIdentifier, model.UserId.ToString()),
        //        new Claim(ClaimTypes.Email, model.EmailAddress),
        //        new Claim(ClaimTypes.Name, model.Name) };

        //    // generate claimsIdentity on the name of the class
        //    var claimsIdentity = new ClaimsIdentity(claims,
        //                nameof(ValidateHashAuthenticationHandler));

        //    // generate AuthenticationTicket from the Identity
        //    // and current authentication scheme
        //    var ticket = new AuthenticationTicket(
        //        new ClaimsPrincipal(claimsIdentity), this.Scheme.Name);

        //    // pass on the ticket to the middleware
        //    return Task.FromResult(AuthenticateResult.Success(ticket));
        //}

        return Task.FromResult(AuthenticateResult.Fail("Model is Empty"));
    }

}
public class TokenModel
{
    public int UserId { get; set; }
    public string Name { get; set; }
    public string EmailAddress { get; set; }
}

Startup.cs 将以下代码添加到 ConfigureServices 方法中:

            services.AddAuthentication(options =>
            {
                options.DefaultScheme
                    = "Test";
            })
.AddScheme<ValidateHashAuthenticationSchemeOptions, ValidateHashAuthenticationHandler>
        ("Test", null);

用法:

控制器:

[Authorize(AuthenticationSchemes = "Test")]

所以我现在通过创建自定义身份验证处理程序以不同的方式为自己解决了这个问题,它负责检查发送到 Web 的 Cookie API。

Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
    // ...
    services.AddSingleton(new KratosService("http://localhost:4433"));   
    services
        .AddAuthentication("Kratos")
        .AddScheme<KratosAuthenticationOptions, KratosAuthenticationHandler>("Kratos", null);
    // ...
}

如果您对为我完成这项工作的实现感兴趣,我在下面附上了附加文件。还值得一提的是,Kratos 支持两种身份验证方式:Cookie 和 Bearer Tokens,具体取决于您是通过 Web 应用程序还是通过 API 与它对话。我的实现支持两者。您可以在此处找到带有 ASP.NET Core 和 React 的工作示例:https://github.com/robinmanuelthiel/kratos-demo

KratosAuthenticationHandler.cs:

using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace KratosDemo.Server.Kratos
{
    public class KratosAuthenticationOptions : AuthenticationSchemeOptions
    {
    }

    public class KratosAuthenticationHandler : AuthenticationHandler<KratosAuthenticationOptions>
    {        
        readonly KratosService _kratosService;
        readonly string _sessionCookieName = "ory_kratos_session";

        public KratosAuthenticationHandler(
            IOptionsMonitor<KratosAuthenticationOptions> options, 
            ILoggerFactory logger, 
            UrlEncoder encoder, 
            ISystemClock clock,
            KratosService kratosService
        ) 
            : base(options, logger, encoder, clock)
        {
            _kratosService = kratosService;
        }

        protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
        {
            // ORY Kratos can authenticate against an API through two different methods:
            // Cookie Authentication is for Browser Clients and sends a Session Cookie with each request.
            // Bearer Token Authentication is for Native Apps and other APIs and sends an Authentication header with each request.
            // We are validating both ways here by sending a /whoami request to ORY Kratos passing the provided authentication
            // methods on to Kratos.
            try
            {                
                // Check, if Cookie was set
                if (Request.Cookies.ContainsKey(_sessionCookieName))
                {
                    var cookie = Request.Cookies[_sessionCookieName];
                    var id = await _kratosService.GetUserIdByCookie(_sessionCookieName, cookie);
                    return ValidateToken(id);
                }

                // Check, if Authorization header was set
                if (Request.Headers.ContainsKey("Authorization"))
                {
                    var token = Request.Headers["Authorization"];
                    var id = await _kratosService.GetUserIdByToken(token);
                    return ValidateToken(id);
                }

                // If neither Cookie nor Authorization header was set, the request can't be authenticated.
                return AuthenticateResult.NoResult();
            }
            catch (Exception ex)
            {
                // If an error occurs while trying to validate the token, the Authentication request fails.
                return AuthenticateResult.Fail(ex.Message);
            }
        }

        private AuthenticateResult ValidateToken(string userId)
        {            
            var claims = new List<Claim>
            {
                new Claim(ClaimTypes.NameIdentifier, userId),
            };
 
            var identity = new ClaimsIdentity(claims, Scheme.Name);
            var principal = new System.Security.Principal.GenericPrincipal(identity, null);
            var ticket = new AuthenticationTicket(principal, Scheme.Name);
            return AuthenticateResult.Success(ticket);
        } 
    }
}

KratosService.cs:

using System.Threading.Tasks;
using System.Net.Http;
using System.Text.Json;
using System;

namespace KratosDemo.Server.Kratos
{     
    public class KratosService
    {
        private readonly string _kratosUrl;
        private readonly HttpClient _client;

        public KratosService(string kratosUrl)
        {
            _client = new HttpClient();
            _kratosUrl = kratosUrl;
        }

        public async Task<string> GetUserIdByToken(string token)
        {
            var request = new HttpRequestMessage(HttpMethod.Get, $"{_kratosUrl}/sessions/whoami");
            request.Headers.Add("Authorization", token);
            return await SendWhoamiRequestAsync(request);            
        }
        
        public async Task<string> GetUserIdByCookie(string cookieName, string cookieContent)
        {
            var request = new HttpRequestMessage(HttpMethod.Get, $"{_kratosUrl}/sessions/whoami");
            request.Headers.Add("Cookie", $"{cookieName}={cookieContent}");
            return await SendWhoamiRequestAsync(request);            
        }

        private async Task<string> SendWhoamiRequestAsync(HttpRequestMessage request)
        {
            var res = await _client.SendAsync(request);
            res.EnsureSuccessStatusCode();

            var json = await res.Content.ReadAsStringAsync();
            var whoami = JsonSerializer.Deserialize<Whoami>(json);    
            if (!whoami.Active)
                throw new InvalidOperationException("Session is not active.");

            return whoami.Identity.Id;
        }
    }
}

Whoami.cs:

using System;
using System.Text.Json.Serialization;

namespace KratosDemo.Server.Kratos
{
    public class Whoami
    {
        [JsonPropertyName("id")]
        public string Id { get; set; } 

        [JsonPropertyName("active")]
        public bool Active { get; set; } 

        [JsonPropertyName("expires_at")]
        public DateTime ExpiresAt { get; set; } 

        [JsonPropertyName("authenticated_at")]
        public DateTime AuthenticatedAt { get; set; } 

        [JsonPropertyName("issued_at")]
        public DateTime IssuedAt { get; set; } 

        [JsonPropertyName("identity")]
        public Identity Identity { get; set; } 
    }

    public class Identity
    {
        [JsonPropertyName("id")]
        public string Id { get; set; }
    }
}