ROPC 和刷新令牌配合 ASP.NET Core Identity
通过强类型 API 实现资源所有者密码凭据授权和刷新令牌配合 ASP.NET Core Identity
引言
ASP.NET Identity 和 ASP.NET Core Identity 的脚手架代码已经为用户/用户名密码登录以及与 Google、Facebook 和 Apple 等第三方认证提供商的接口提供了一个基本框架。本文介绍
授权服务器是“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) | | +---------+ +---------------+
备注
- 本文仅涵盖“用户代理应用程序”的“公共客户端”。
背景
本文是对以下文章的跟进
使用代码
在 RFC6749 的负载示例和许多 OAuth2 的实现中,令牌负载都是通过一个单一的 "/token" 端点。
在构建 ASP.NET Core Web API 时,技术上你可能只需要使用弱动态类型 "object",就像你在 JavaScript 编程中那样。本文介绍在令牌端点中使用“多态模型绑定”,用于处理“application/x-www-form-urlencoded
”请求。
令牌请求模型
[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; }
}
令牌端点
[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 代码
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 疲劳:黑客在高调泄露事件中的新宠策略
- OAuth 2.1:根据 [I-D.ietf-oauth-security-topics] 的 第 2.4 节,资源所有者密码凭据授权类型已从该规范中省略。
你是否同意 ROPC 应该被弃用?
我不是安全专家。据我所知,典型 MFA 除了主要的访问设备(如 PC)外,还需要额外的设备,如短信、智能手机应用程序或 USB 密钥。有些人群根本无法处理 MFA 的流程。
事实上,在澳大利亚,对于网上银行,一些主要银行在网上银行浏览器上只需要客户 ID + 密码。即使在新的 PC 上首次登录,也不需要短信验证码。我确信前后端的安全机制在幕后进行了许多检查,但至少从消费者的角度来看,他们只需要用户名 + 密码。只有首次向新收款人付款才需要短信验证,但这属于授权,而不是登录认证。便利性与字面安全性之间的平衡在于真正为用户提供安全和价值。
你能想到一些 ROPC 仍然是适用于特定场景的良好旧技术的更多场景吗?
我可以想到 2 种场景
- 大部分退休人员,他们几乎不会使用智能手机,或者很容易被“山寨”应用搞糊涂。
- 许多学龄儿童和学龄前儿童没有智能手机。
如何在 ROPC 的基础上提高安全性是另一个大话题。