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

4
我正在使用ORY Kratos进行身份验证,我的前端SPA(React App)通过Kratos登录服务器进行身份验证,并返回会话cookie。现在我想以某种方式保护我的ASP.NET Core Web Api,使用户只能在请求中附加有效的cookie时才能调用受[Authorize]属性保护的某些方法。为此,我需要验证每个传入请求中的cookie。因此,我正在寻找一种配置认证并添加自定义逻辑以验证cookie的方法(我需要向Kratos发出API调用以验证它)。我要验证的cookie不是由要验证它的ASP.NET Core应用程序发布的。迄今为止,我找到的所有示例都是在同一台服务器上发放cookie,但我需要验证外部cookie。这就是我的cookie的样子: enter image description here 在开发工具中,我可以验证Cookie是否连接到请求标头: enter image description here 这是我目前尝试过的内容:
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,它将永远无法触发ValidatePrincipal方法。建议您可以使用F12开发工具的网络功能来查看该请求是否已将其添加到请求头中,如所示。 - Brando Zhang
@BrandoZhang 是的,那是肯定的。如果我的问题有误导之处,我很抱歉,但Cookie会随请求一起发送到API。 - Robin-Manuel Thiel
2个回答

2
根据 cookie 认证处理程序的 源代码,我发现它会在进入 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类,并在startup.cs中直接注册该类。 然后,你可以使用[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")]

谢谢您的回复!创建自己的身份验证处理程序也是我现在想到的解决方案。如果有人对 ORY Kratos 的专用实现感兴趣,我已经在这里发布了我的结果作为答案。 - Robin-Manuel Thiel

1

所以我通过创建自定义的身份验证处理程序来解决它,该处理程序负责检查发送到Web API的Cookie。

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 Token,具体取决于您是通过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; }
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接