65.9K
CodeProject 正在变化。 阅读更多。
Home

ROPC 和刷新令牌配合 ASP.NET Core Identity

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2024 年 7 月 12 日

CPOL

4分钟阅读

viewsIcon

6694

通过强类型 API 实现资源所有者密码凭据授权和刷新令牌配合 ASP.NET Core Identity

引言

ASP.NET Identity 和 ASP.NET Core Identity 的脚手架代码已经为用户/用户名密码登录以及与 Google、Facebook 和 Apple 等第三方认证提供商的接口提供了一个基本框架。本文介绍

  1. 用于 ROPC 和刷新令牌的单一 API 端点,符合 OAuth 2.0 授权框架 (RFC6749) 的 第 4.3 节第 6 节
  2. 令牌 API 是强类型的。

授权服务器是“Core3WebApi”,特别是认证端点是“AuthController.cs”。

     +----------+
     | Resource |
     |  Owner   |
     |          |
     +----------+
          v
          |    Resource Owner
         (A) Password Credentials
          |
          v
     +---------+                                  +---------------+
     |         |>--(B)---- Resource Owner ------->|               |
     |         |         Password Credentials     | Authorization |
     | Client  |                                  |     Server    |
     |         |<--(C)---- Access Token ---------<|               |
     |         |    (w/ Optional Refresh Token)   |               |
     +---------+                                  +---------------+

备注

背景

本文是对以下文章的跟进

  1. 解耦 ASP.NET Core Identity、认证和数据库引擎
  2. 在 ASP.NET Core Web API 之间共享 Identity Bearer 令牌

使用代码

在 RFC6749 的负载示例和许多 OAuth2 的实现中,令牌负载都是通过一个单一的 "/token" 端点。

在构建 ASP.NET Core Web API 时,技术上你可能只需要使用弱动态类型 "object",就像你在 JavaScript 编程中那样。本文介绍在令牌端点中使用“多态模型绑定”,用于处理“application/x-www-form-urlencoded”请求。

令牌请求模型

第 4.3.2 节第 6 节中所述

    [DataContract]
    public class RequestBase
    {
        [Required]
        [JsonPropertyName("grant_type")]
        [JsonPropertyOrder(-10)]
        [DataMember(Name = "grant_type")]
        public string grant_type { get; protected set; }
    }

    /// <summary>
    /// Section 4.3 and 4.3.2.
    /// GrantType must be Value MUST be set to "password".
    /// </summary>
    [DataContract]
    public class ROPCRequst : RequestBase
    {
        public ROPCRequst()
        {
            grant_type = "password";
        }

        [Required]
        [DataMember]
        public string Username { get; set; }

        [Required]
        [DataMember]
        public string Password { get; set; }

        [DataMember]
        public string Scope { get; set; }

    }

    /// <summary>
    /// Section 6
    /// Grant type MUST be set to "refresh_token".
    /// </summary>
    [DataContract]
    public class RefreshAccessTokenRequest : RequestBase
    {
        public RefreshAccessTokenRequest()
        {
            grant_type = "refresh_token";
        }

        [Required]
        [JsonPropertyName("refresh_token")]
        [DataMember(Name = "refresh_token")]
        public string refresh_token { get; set; }

        [DataMember]
        public string Scope { get; set; }
    }

令牌端点

AuthController.cs:

[AllowAnonymous]
[Consumes("application/x-www-form-urlencoded")] // redundant generally because of FromForm below
[HttpPost]
public async Task<ActionResult<TokenResponseModel>> Authenticate([FromForm] RequestBase model)
{
    if (model is ROPCRequst)
    {
        ROPCRequst ropcRequest = model as ROPCRequst;
        ApplicationUser user = await UserManager.FindByNameAsync(ropcRequest.Username);
        if (user == null)
        {
            return Unauthorized(new { message = "Username or password is invalid" });
        }

        bool passwordIsCorrect = await UserManager.CheckPasswordAsync(user, ropcRequest.Password);
        if (!passwordIsCorrect)
        {
            return Unauthorized(new { message = "Username or password is incorrect" });
        }

        var tokenHelper = new UserTokenHelper(UserManager, symmetricSecurityKey, authSettings);
        return await tokenHelper.GenerateJwtToken(user, ropcRequest.Username, Guid.Empty); //todo: some apps may need to deal with scope
    }
    else if (model is RefreshAccessTokenRequest refreshAccessTokenRequest)
    {
        if (AuthenticationHeaderValue.TryParse(Request.Headers.Authorization, out var headerValue)){
            var scehma = headerValue.Scheme;
            Debug.Assert("bearer".Equals(scehma, StringComparison.OrdinalIgnoreCase));
            var accessToken = headerValue.Parameter;
            var jwtSecurityToken = new JwtSecurityTokenHandler().ReadJwtToken(accessToken);
            var uniqueNameClaim = jwtSecurityToken.Claims.Single(d => d.Type == "unique_name");
            var username = uniqueNameClaim.Value;
            var user = await UserManager.FindByNameAsync(username);

            if (user == null)
            {
                return BadRequest(new { message = "Username or password is invalid" });
            }

            var tokenHelper = new UserTokenHelper(UserManager, symmetricSecurityKey, authSettings);
            var tokenTextExisting = await tokenHelper.MatchToken(user, "RefreshToken", refreshAccessTokenRequest.refresh_token, Guid.Empty);
            if (!tokenTextExisting)
            {
                return StatusCode(401, new { message = "Invalid to retrieve token through refreshToken" }); // message may be omitted in prod build, to avoid exposing implementation details.
            }

            return await tokenHelper.GenerateJwtToken(user, username, Guid.Empty);
        }
    }

    throw new NotSupportedException();
}

多态模型绑定

多态模型绑定的技术细节超出了本文的范围,请搜索“ASP.NET Core polymorphic model binding”以获取相关文章。

OAuth2RequestBinderProvider.cs:

public class OAuth2RequestBinderProvider : Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context.Metadata.ModelType != typeof(RequestBase))
        {
            return null;
        }

        var subclasses = new[] { typeof(ROPCRequst), typeof(RefreshAccessTokenRequest), };

        var binders = new Dictionary<Type, (ModelMetadata, IModelBinder)>();
        foreach (var type in subclasses)
        {
            var modelMetadata = context.MetadataProvider.GetMetadataForType(type);
            binders[type] = (modelMetadata, context.CreateBinder(modelMetadata));
        }

        return new RequestModelBinder(binders);
    }
}

public class RequestModelBinder : IModelBinder
{
    private Dictionary<Type, (ModelMetadata, IModelBinder)> binders;

    public RequestModelBinder(Dictionary<Type, (ModelMetadata, IModelBinder)> binders)
    {
        this.binders = binders;
    }

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext.HttpContext.Request.ContentType.Contains("application/json"))
        {
            return;
        }

        var modelKindName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, "grant_type");
        var modelTypeValue = bindingContext.ValueProvider.GetValue(modelKindName).FirstValue;

        IModelBinder modelBinder;
        ModelMetadata modelMetadata;
        if (modelTypeValue == "password")
        {
            (modelMetadata, modelBinder) = binders[typeof(ROPCRequst)];
        }
        else if (modelTypeValue == "refresh_token")
        {
            (modelMetadata, modelBinder) = binders[typeof(RefreshAccessTokenRequest)];
        }
        else
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return;
        }

        var newBindingContext = DefaultModelBindingContext.CreateBindingContext(
            bindingContext.ActionContext,
            bindingContext.ValueProvider,
            modelMetadata,
            bindingInfo: null,
            bindingContext.ModelName);

        await modelBinder.BindModelAsync(newBindingContext);
        bindingContext.Result = newBindingContext.Result;

        if (newBindingContext.Result.IsModelSet)
        {
            bindingContext.ValidationState[newBindingContext.Result.Model] = new ValidationStateEntry
            {
                Metadata = modelMetadata,
            };
        }
    }
}

然后在启动代码中注册提供程序

builder.Services.AddControllers(configure =>
{
    configure.ModelBinderProviders.Insert(0, new OAuth2RequestBinderProvider());
})

请求令牌的客户端 API 代码

AuthClient.cs:

public class AuthClient
{
    private System.Net.Http.HttpClient client;

    private JsonSerializerOptions jsonSerializerSettings;

    public AuthClient(System.Net.Http.HttpClient client, JsonSerializerOptions jsonSerializerSettings = null)
    {
        if (client == null)
            throw new ArgumentNullException(nameof(client), "Null HttpClient.");

        if (client.BaseAddress == null)
            throw new ArgumentNullException(nameof(client), "HttpClient has no BaseAddress");

        this.client = client;
        this.jsonSerializerSettings = jsonSerializerSettings;
    }

    public async Task<Fonlow.Auth.Models.Client.AccessTokenResponse> PostRopcTokenRequestAsFormDataToAuthAsync(Fonlow.Auth.Models.Client.ROPCRequst model, Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
    {
        var requestUri = "token";
        using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri);
        var pairs = new KeyValuePair<string, string>[]
                    {
                        new KeyValuePair<string, string>( "grant_type", model.grant_type ),
                        new KeyValuePair<string, string>( "username", model.Username ),
                        new KeyValuePair<string, string> ( "password", model.Password )
                    };
        var content = new FormUrlEncodedContent(pairs);
        httpRequestMessage.Content = content;
        handleHeaders?.Invoke(httpRequestMessage.Headers);
        using var responseMessage = await client.SendAsync(httpRequestMessage);
        responseMessage.EnsureSuccessStatusCodeEx();
        var stream = await responseMessage.Content.ReadAsStreamAsync();
        return JsonSerializer.Deserialize<Fonlow.Auth.Models.Client.AccessTokenResponse>(stream, jsonSerializerSettings);
    }

    public async Task<Fonlow.Auth.Models.Client.AccessTokenResponse> PostRefreshTokenRequestAsFormDataToAuthAsync(Fonlow.Auth.Models.Client.RefreshAccessTokenRequest model, Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
    {
        var requestUri = "token";
        using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri);
        var pairs = new KeyValuePair<string, string>[]
                    {
                        new KeyValuePair<string, string>( "grant_type", model.grant_type ),
                        new KeyValuePair<string, string>( "refresh_token", model.refresh_token ),
                        new KeyValuePair<string, string> ( "scope", model.Scope )
                    };
        var content = new FormUrlEncodedContent(pairs);
        httpRequestMessage.Content = content;
        handleHeaders?.Invoke(httpRequestMessage.Headers);
        using var responseMessage = await client.SendAsync(httpRequestMessage);
        responseMessage.EnsureSuccessStatusCodeEx();
        var stream = await responseMessage.Content.ReadAsStreamAsync();
        return JsonSerializer.Deserialize<Fonlow.Auth.Models.Client.AccessTokenResponse>(stream, jsonSerializerSettings);
    }
}

提示

  • 虽然我手动编写了 AuthClient.cs,但 `Fonlow.Auth.Models.Client.ROPCRequst` 以及其他客户端 API 代码是由“强类型客户端 API 生成器”生成的。
/// <summary>
/// Section 4.3 and 4.3.2.
/// GrantType must be Value MUST be set to "password".
/// </summary>
[System.Runtime.Serialization.DataContract(Namespace="http://demoapp.client/2024")]
public class ROPCRequst : Fonlow.Auth.Models.Client.RequestBase
{
    
    /// <summary>
    /// Required
    /// </summary>
    [System.ComponentModel.DataAnnotations.Required()]
    [System.Runtime.Serialization.DataMember()]
    public string Password { get; set; }
    
    [System.Runtime.Serialization.DataMember()]
    public string Scope { get; set; }
    
    /// <summary>
    /// Required
    /// </summary>
    [System.ComponentModel.DataAnnotations.Required()]
    [System.Runtime.Serialization.DataMember()]
    public string Username { get; set; }
}

集成测试

此测试首先获取访问令牌,然后使用经过身份验证的客户端来刷新令牌。

[Fact]
public async Task TestPostRefreshTokenRequestAsFormDataToAuthAsync()
{
    var ra = await api.PostRopcTokenRequestAsFormDataToAuthAsync(new ROPCRequst
    {
        grant_type = "password",
        Username = "admin",
        Password = "Pppppp*8"
    });

    HttpClient client = new HttpClient();
    client.BaseAddress = new Uri(baseUrl);
    client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", ra.access_token);
    AuthClient authClient = new AuthClient(client);
    var r = await authClient.PostRefreshTokenRequestAsFormDataToAuthAsync(new RefreshAccessTokenRequest
    {
        grant_type = "refresh_token",
        refresh_token = ra.refresh_token
    });

    Assert.Equal("bearer", r.token_type, true);
    Assert.NotNull(r.access_token);
    Assert.NotNull(r.refresh_token);
    Assert.True(r.expires_in > 0);
}

关注点

多态绑定与 C# 属性命名约定的遵循

你可能想知道为什么令牌请求模型中的某些属性名称不遵循标准的 C# 属性命名约定。问题在于,请求负载的编码是“application/x-www-form-urlencoded”,而 ASP.NET Core 的请求模型绑定不会遵守 `JsonPropertyNameAttribute` 中声明的内容,运行时最多只能将 TitleCase 转换为 camelCase。

似乎 ASP.NET Core 中没有专门用于处理与名为 "AccessToken" 的属性关联的 "access_token" 的内置机制。如果您发现了任何方法或有任何见解,请随时在评论区分享您的发现。

替代认证服务

作为一名 .NET 开发人员,你之所以查看本文及其存储库,可能是因为

  • 你不想使用 Okta、Auth0、Microsoft Azure AD / Entra 等商业认证服务,至少目前不想,原因有很多。
  • 你想尽可能遵循 RFC6749,以满足基本安全标准,并轻松迁移到更全面的认证服务(商业或免费)。

曾经有一个流行的免费认证服务器“Identity Server”,该服务器于 2022 年停产。然而,DuendeSoftware 已接管。在考虑更全面的认证服务器并将其集成到你的业务应用程序中时,你可能需要查看一下

ROPC 将被弃用

不管你是否喜欢,ROPC 都将被弃用,尽管它在某些情况下对于小型应用程序可能很方便,例如,你的目标用户因为某些原因不喜欢 2FA/MFA。此外,它可能是从 OAuth2 提供商 A 迁移到 OAuth2 提供商 B 的过渡解决方案的一部分。

参考文献

你是否同意 ROPC 应该被弃用?

我不是安全专家。据我所知,典型 MFA 除了主要的访问设备(如 PC)外,还需要额外的设备,如短信、智能手机应用程序或 USB 密钥。有些人群根本无法处理 MFA 的流程。

事实上,在澳大利亚,对于网上银行,一些主要银行在网上银行浏览器上只需要客户 ID + 密码。即使在新的 PC 上首次登录,也不需要短信验证码。我确信前后端的安全机制在幕后进行了许多检查,但至少从消费者的角度来看,他们只需要用户名 + 密码。只有首次向新收款人付款才需要短信验证,但这属于授权,而不是登录认证。便利性与字面安全性之间的平衡在于真正为用户提供安全和价值。

你能想到一些 ROPC 仍然是适用于特定场景的良好旧技术的更多场景吗?

我可以想到 2 种场景

  1. 大部分退休人员,他们几乎不会使用智能手机,或者很容易被“山寨”应用搞糊涂。
  2. 许多学龄儿童和学龄前儿童没有智能手机。

如何在 ROPC 的基础上提高安全性是另一个大话题。

© . All rights reserved.