在 ASP.NET Core 6.0 中构建自己的 OAuth 2.0 服务器和 OpenId Connect 提供程序
2023 年 4 月 20 日更新
由于我为该 OAuth 服务器添加了更多功能,因此本文已过时,因此为了保持更新,我建议下载该项目或从我的 GitHub 仓库克隆它
是的,我知道这篇文章的标题很有趣,希望您能享受与我一起构建 OAuth 2.0 服务器和 OpenId Connect 提供程序的旅程。
我和您,我们是一个团队。
不过,等等,我们团队里还有一位成员,让我来介绍一下他,Specification。
我不会在这里向您解释 OAuth 2.0 和 OpenId Connect 的工作原理,但我会向您解释如何实现规范中的内容,至少是基础知识和基本原理。
我们将要构建的授权服务器非常简单,但却很完整。
如果你对阅读本文不感兴趣,不用担心,你可以从我的 Github repo 下载完整的源代码。
首先,让我向您概述一下我们将在这里建造什么。
- 授权服务器(在 ASP.NET Core MVC 中)
- ASP.NET Core MVC 应用程序
首先让我们创建我们的客户端应用程序(ASP.NET Core MVC)
打开 Visual Studio 并创建一个名为PlatformNet6的空 ASP.NET Core 应用程序(见下文) (您可以给它起一个您喜欢的名字)
选择NET6(LTS)版本(见下图)
创建一个名为Controllers的文件夹,并在其中创建一个名为HomeController.cs
的新控制器类 。在刚刚创建的 HomeController 类中,你会发现一个名为Index的操作方法。右键单击项目名称,然后创建一个名为 Views 的新文件夹。返回到 HomeControllers.cs 上的 Index 操作方法,右键单击它,然后从菜单中选择“添加视图
”选项。HomeController 类应如下所示:
namespace PlatformNet6.Controllers
{
[Authorize]
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
}
}
通过使用 Nuget 包安装此库:
Microsoft.AspNetCore.Authentication.OpenIdConnect
打开 Program.cs 类并删除如下所示的代码:
var builder = WebApplication.CreateBuilder(args);
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
builder.Services.AddAuthentication(config =>
{
config.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
config.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
// this is my Authorization Server Port
options.Authority = "https://localhost:7275";
options.ClientId = "platformnet6";
options.ClientSecret = "123456789";
options.ResponseType = "code";
options.CallbackPath = "/signin-oidc";
options.SaveTokens = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = false,
SignatureValidator = delegate(string token, TokenValidationParameters validationParameters)
{
var jwt = new JwtSecurityToken(token);
return jwt;
},
};
});
builder.Services.AddControllersWithViews();
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapDefaultControllerRoute();
});
app.Run();
在上面的代码中,我使用了AddOpenIdConnect扩展,当在任何 Action 方法或控制器上发现任何 Authorize 属性时,它会通知 ASP.NET Core 将应用程序重定向到授权服务器。我将使用Cookies作为默认身份验证方案,并使用 OpenIdConnect 作为默认质询方案。
好了,客户端应用程序完成了,我们将返回此应用程序来完成 Index.cshtml 页面的内容。
授权服务器项目
在本节中,我们将构建我们的授权服务器,本节是本篇文章中的邮件部分。
打开 Visual Studio 并创建一个名为OAuth20.Server的空 ASP.NET Core 应用程序(见下文) (您可以给它起一个您喜欢的名字)
请查看解决方案的名称是OAuth20
选择NET6(LTS)版本(见下图)
在我们的应用程序的根目录中创建这些文件夹:
- 控制器
- 常见的
- 端点
- 模型
- Oauth请求
- Oauth响应
- 服务
- 视图
我们的授权服务器的结构如下图所示
通过使用 Nuget 包安装此库:
System.IdentityModel.Tokens.Jwt
如果你查看规范https://www.rfc-editor.org/rfc/rfc6749 ,你会发现OAuth 2.0 的主要角色是客户端,这里的客户端指的是资源所有者使用的应用程序,而资源所有者指的是使用该应用程序的人。例如,我是Mohammed Ahmed Hussien,我使用Dev.to阅读最新的软件文章。
这里,Dev.to是客户端,Mohammed Ahmed Hussien是资源所有者。
在 Model 文件夹中创建一个名为 Client.cs 的新类。
using System.Collections.Generic;
namespace OAuth20.Server.Models
{
public class Client
{
public Client()
{
}
public string ClientName { get; set; }
public string ClientId { get; set; }
/// <summary>
/// Client Password
/// </summary>
public string ClientSecret { get; set; }
public IList<string> GrantType { get; set; }
/// <summary>
/// by default false
/// </summary>
public bool IsActive { get; set; } = false;
public IList<string> AllowedScopes { get; set; }
public string ClientUri { get; set; }
public string RedirectUri { get; set; }
}
}
规范规定RedirectUri必须接受 URI 数组,而正如您在 Client.cs 类中看到的,RedirectUri只接受字符串值,不接受数组值。不过我保证稍后会解决这个问题,相信我!
OpenId Connect 建立在 OAuth2.0 协议之上,其主要目的是验证用户身份。如果您还记得,我们在 PlatformNet6 应用程序中使用AddOpenIdConnect扩展来验证用户身份,该扩展会扫描名为.well-known/openid-configuration的端点,并且该端点应该返回包含 OpenId Connect 所需所有信息的JSON响应。更多详情,请参阅 OpenId Connect 文档: https://openid.net/specs/openid-connect-discovery-1_0.html
。在 Endpoints 文件夹中,创建一个名为 DiscoveryResponse.cs 的新类,并将以下代码粘贴到其中:
using System.Collections.Generic;
namespace OAuth20.Server.Endpoints
{
public class DiscoveryResponse
{
public string issuer { get; set; }
public string authorization_endpoint { get; set; }
public string token_endpoint { get; set; }
public IList<string> token_endpoint_auth_methods_supported { get; set; }
public IList<string> token_endpoint_auth_signing_alg_values_supported { get; set; }
public string userinfo_endpoint { get; set; }
public string check_session_iframe { get; set; }
public string end_session_endpoint { get; set; }
public string jwks_uri { get; set; }
public string registration_endpoint { get; set; }
public IList<string> scopes_supported { get; set; }
public IList<string> response_types_supported { get; set; }
public IList<string> acr_values_supported { get; set; }
public IList<string> subject_types_supported { get; set; }
public IList<string> userinfo_signing_alg_values_supported { get; set; }
public IList<string> userinfo_encryption_alg_values_supported { get; set; }
public IList<string> userinfo_encryption_enc_values_supported { get; set; }
public IList<string> id_token_signing_alg_values_supported { get; set; }
public IList<string> id_token_encryption_alg_values_supported { get; set; }
public IList<string> id_token_encryption_enc_values_supported { get; set; }
public IList<string> request_object_signing_alg_values_supported { get; set; }
public IList<string> display_values_supported { get; set; }
public IList<string> claim_types_supported { get; set; }
public IList<string> claims_supported { get; set; }
public bool claims_parameter_supported { get; set; }
public string service_documentation { get; set; }
public IList<string> ui_locales_supported { get; set; }
}
}
该类最重要的属性是:
- 发行者(是身份提供者的域名)
- authorization_endpoint(验证客户端的端点)
- token_endpoint(返回身份令牌和访问令牌给客户端)正如我之前所说,AddOpenIdConnect 扩展会扫描名为.well-known/openid-configuration 的端点,所以让我们创建这个端点
在 Controllers 文件夹中创建一个名为 DiscoveryEndpointController.cs 的新控制器类,粘贴如下所示的代码:
using Microsoft.AspNetCore.Mvc;
using OAuth20.Server.Endpoints;
namespace OAuth20.Server.Controllers
{
public class DiscoveryEndpointController : Controller
{
// .well-known/openid-configuration
[HttpGet("~/.well-known/openid-configuration")]
public JsonResult GetConfiguration()
{
var response = new DiscoveryResponse
{
issuer = "https://localhost:7275",
authorization_endpoint = "https://localhost:7275/Home/Authorize",
token_endpoint = "https://localhost:7275/Home/Token",
token_endpoint_auth_methods_supported = new string[] { "client_secret_basic", "private_key_jwt" },
token_endpoint_auth_signing_alg_values_supported = new string[] { "RS256", "ES256" },
acr_values_supported = new string[] {"urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"},
response_types_supported = new string[] { "code", "code id_token", "id_token", "token id_token" },
subject_types_supported = new string[] { "public", "pairwise" },
userinfo_encryption_enc_values_supported = new string[] { "A128CBC-HS256", "A128GCM" },
id_token_signing_alg_values_supported = new string[] { "RS256", "ES256", "HS256" },
id_token_encryption_alg_values_supported = new string[] { "RSA1_5", "A128KW" },
id_token_encryption_enc_values_supported = new string[] { "A128CBC-HS256", "A128GCM" },
request_object_signing_alg_values_supported = new string[] { "none", "RS256", "ES256" },
display_values_supported = new string[] { "page", "popup" },
claim_types_supported = new string[] { "normal", "distributed" },
scopes_supported = new string[] { "openid", "profile", "email", "address", "phone", "offline_access" },
claims_supported = new string[] { "sub", "iss", "auth_time", "acr", "name", "given_name",
"family_name", "nickname", "profile", "picture", "website", "email", "email_verified",
"locale", "zoneinfo" },
claims_parameter_supported = true,
service_documentation = "https://localhost:7275/connect/service_documentation.html",
ui_locales_supported = new string[] { "en-US", "en-GB", "en-CA", "fr-FR", "fr-CA" }
};
return Json(response);
}
}
}
该Action方法的返回类型为josn result,该端点的名称在HttpGet属性内部修饰
[HttpGet("~/.well-known/openid-configuration")]
确保将端点的端口更改为 Visual Studio 为您创建的端口。
我自己的端口是https://localhost:7275
要检查一切是否正常工作,请在此端点(.well-known/openid-configuration)中点击断点并运行OAuth20.Server授权服务器应用程序,然后运行PlatformNet6应用程序,您将发现断点已被触发。
确保 PlatformNet6 应用程序的设置
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
// this is my Authorization Server Port
options.Authority = "https://localhost:7275";
options.ClientId = "platformnet6";
options.ClientSecret = "123456789";
options.ResponseType = "code";
options.CallbackPath = "/signin-oidc";
options.SaveTokens = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = false,
SignatureValidator = delegate(string token, TokenValidationParameters validationParameters)
{
var jwt = new JwtSecurityToken(token);
return jwt;
},
};
});
到目前为止,我们已准备好授权服务器,以便接受来自任何客户端的调用。
让我们打破僵局,创建一个通用的 C# 扩展方法。
在 Common 文件夹中,创建一个名为 ExtensionMethods.cs 的新类,该类的签名如下:
using System.ComponentModel;
using System;
using System.Linq;
namespace OAuth20.Server.Common
{
public static class ExtensionMethods
{
public static string GetEnumDescription(this Enum en)
{
if (en == null) return null;
var type = en.GetType();
var memberInfo = type.GetMember(en.ToString());
var description = (memberInfo[0].GetCustomAttributes(typeof(DescriptionAttribute),
false).FirstOrDefault() as DescriptionAttribute)?.Description;
return description;
}
public static bool IsRedirectUriStartWithHttps(this string redirectUri)
{
if(redirectUri != null && redirectUri.StartsWith("https")) return true;
return false;
}
}
}
我不会创建具有字符串类型 const 字段的静态类,而是使用 Enum 类型,并且我会用Description 属性来修饰 Enum 中的任何字段,因此如果我需要此属性内的值,我将使用GetEnumDescription扩展方法。
在 Model 文件夹中创建名为AuthorizationGrantTypesEnum.cs的枚举类型
using System.ComponentModel;
namespace OAuth20.Server.Models
{
internal enum AuthorizationGrantTypesEnum : byte
{
[Description("code")]
Code,
[Description("Implicit")]
Implicit,
[Description("ClientCredentials")]
ClientCredentials,
[Description("ResourceOwnerPassword")]
ResourceOwnerPassword
}
}
返回 Model 文件夹,创建一个名为GrantTypes.cs的新类
using OAuth20.Server.Common;
using System.Collections.Generic;
namespace OAuth20.Server.Models
{
public class GrantTypes
{
public static IList<string> Code =>
new[] { AuthorizationGrantTypesEnum.Code.GetEnumDescription() };
public static IList<string> Implicit =>
new[] { AuthorizationGrantTypesEnum.Implicit.GetEnumDescription() };
public static IList<string> ClientCredentials =>
new[] { AuthorizationGrantTypesEnum.ClientCredentials.GetEnumDescription() };
public static IList<string> ResourceOwnerPassword =>
new[] { AuthorizationGrantTypesEnum.ResourceOwnerPassword.GetEnumDescription() };
}
}
有关此类的更多详细信息,请参阅规范:https://www.rfc-editor.org/rfc/rfc6749#page-23
AddOpenIdConnect扩展查找的第二个端点是授权端点,该端点的任务是验证客户端。在深入之前,我们先创建客户端存储对象,该对象将保存所有想要使用授权服务器的客户端。在 Model 文件夹中创建一个名为 ClientStore.cs 的新类,该类的内容如下:
using System.Collections.Generic;
namespace OAuth20.Server.Models
{
public class ClientStore
{
public IEnumerable<Client> Clients = new[]
{
new Client
{
ClientName = "platformnet .Net 6",
ClientId = platformnet6",
ClientSecret = "123456789",
AllowedScopes = new[]{ "openid", "profile"},
GrantType = GrantTypes.Code,
IsActive = true,
ClientUri = "https://localhost:7026",
RedirectUri = "https://localhost:7026/signin-oidc"
}
};
}
}
ClientUri和RedirectUri应该指向我们在本文开头创建的 PlatformNet6 应用程序(客户端)。请务必将 Port 替换为 Visual Studio 为您创建的端口(只需替换端口)。ClientName以及ClientId、ClientSecret和GrantType应该与您在PlatformNet6中引入的一致。
请让我们休息一下,然后回来完成这篇文章,我感觉很累,我得喝杯茶
所以,我在这里,我很高兴你也在这里和我在一起!
让我们创建授权端点,但在此之前,我需要解释一下这个端点的工作原理。
如果你还记得,我们在配置.well-known/openid-configuratio端点时,将https://localhost:7275/Home/Authorize 指定为authorization_endpoint属性的值,因此 OpenIdConnect 扩展会扫描名为.well-known/openid-configuratio 的端点,并期望在响应中找到authorization_endpoint的位置,如下所示:
authorization_endpoint = "https://localhost:7275/Home/Authorize"
authorization_endpoint是AddOpenIdConnect查找的第二个位置,在该请求中, AddOpenIdConnect接收了当前请求(HTTPContext)的多个参数,我们来看一下。 在 OauthRequest 文件夹中创建一个名为AuthorizationRequest.cs的新类,其内容如下:
namespace OAuth20.Server.OauthRequest
{
public class AuthorizationRequest
{
public AuthorizationRequest() { }
/// <summary>
/// Response Type, is required
/// </summary>
public string response_type { get; set; }
/// <summary>
/// Client Id, is required
/// </summary>
public string client_id { get; set; }
/// <summary>
/// Redirect Uri, is optional
/// The redirection endpoint URI MUST be an absolute URI as defined by
/// [RFC3986] Section 4.3
/// </summary>
public string redirect_uri { get; set; }
/// <summary>
/// Optional
/// </summary>
public string scope { get; set; }
/// <summary>
/// Return the state in the result
/// if it was present in the client authorization request
/// </summary>
public string state { get; set; }
}
}
那么,让我们深入研究并创建授权端点。
在 Controllers 文件夹内创建一个名为 HomeController.cs 的新控制器,该控制器的内容如下:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using OAuth20.Server.OauthRequest;
namespace OAuth20.Server.Controllers
{
public class HomeController : Controller
{
private readonly IHttpContextAccessor _httpContextAccessor;
public HomeController(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public IActionResult Authorize(AuthorizationRequest authorizationRequest)
{
// The implementation goes here
}
public IActionResult Error(string error)
{
return View(error);
}
}
}
仔细看的话,我使用了 IHttpContextAccessor 接口,它提供了对当前 HttpContext 请求的访问。
你需要在 Program.cs 文件中注册这个接口,如下所示:
builder.Services.AddHttpContextAccessor();
在 HomeController 类中我定义了两个 Action 方法:
- 授权
- 错误
最后一步非常简单,如果发生了任何错误,则将资源所有者重定向到 Error 操作方法。此操作方法接受一个字符串类型的参数,并且错误名称具有标准名称。让我们创建一个枚举类来处理所有这些错误名称。
在 OauthResponse 文件夹中,创建名为 ErrorTypeEnum.cs 的类,如下所示:
using System.ComponentModel;
namespace OAuth20.Server.OauthResponse
{
public enum ErrorTypeEnum : byte
{
[Description("invalid_request")]
InvalidRequest,
[Description("unauthorized_client")]
UnAuthoriazedClient,
[Description("access_denied")]
AccessDenied,
[Description("unsupported_response_type")]
UnSupportedResponseType,
[Description("invalid_scope")]
InValidScope,
[Description("server_error")]
ServerError,
[Description("temporarily_unavailable")]
TemporarilyUnAvailable,
[Description("invalid_grant")]
InvalidGrant,
[Description("invalid_client")]
InvalidClient
}
}
有关错误类型的更多详细信息,请参阅 OAuth2.0 规范
返回HomeController 类中的授权端点(操作方法),在此端点中,我们必须使用存储在 ClientStore.cs 类中的数据来验证请求内部的客户端请求数据顺便说一下,您可以将客户端信息存储在任何后台存储中,例如 SQL Server 数据库,为简单起见,我将其存储在 C# 类中。
如果Authorize * 端点成功验证了客户端,则客户端需要我提供响应结果,为此我们需要一个类来处理该响应的属性,因此在 OauthResponse 文件夹中创建一个名为 AuthorizeResponse.cs 的新类,如下所示:
using System.Collections.Generic;
namespace OAuth20.Server.OauthResponse
{
public class AuthorizeResponse
{
/// <summary>
/// code or implicit grant or client creditional
/// </summary>
public string ResponseType { get; set; }
public string Code { get; set; }
/// <summary>
/// required if it was present in the client authorization request
/// </summary>
public string State { get; set; }
public string RedirectUri { get; set; }
public IList<string> RequestedScopes { get; set; }
public string GrantType { get; set; }
public string Nonce { get; set; }
public string Error { get; set; } = string.Empty;
public string ErrorUri { get; set; }
public string ErrorDescription { get; set; }
public bool HasError => !string.IsNullOrEmpty(Error);
}
}
从 OpenId Connect 规范来看,身份验证响应应该类似如下:
HTTP/1.1 302 Found
Location: https://client.example.org/cb?
code=SplxlOBeZQQYbYS6WxSbIA
&state=af0ifjsldkj
状态来自客户端请求,但代码是密钥,我必须自己(授权服务器)生成并存储在安全的地方。但是,等一下,在客户端验证成功后,我还需要存储所有AuthorizationRequest数据,因为授权服务器向客户端颁发身份令牌和访问令牌时需要这些数据。那么,我该如何存储所有这些信息呢?
在 Model 文件夹中创建一个名为 AuthorizationCode.cs 的新类,如下所示:
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Security.Principal;
namespace OAuth20.Server.Models
{
public class AuthorizationCode
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string RedirectUri { get; set; }
public DateTime CreationTime { get; set; } = DateTime.UtcNow;
public bool IsOpenId { get; set; }
public IList<string> RequestedScopes { get; set; }
public ClaimsPrincipal Subject { get; set; }
public string Nonce { get; set; }
}
}
这个类的名称来自生成代码步骤,我将把这个信息存储在 C#并发字典中,这个字典要求我提供一个键,这个键对于我想存储在这个字典中的任何数据来说都应该是唯一的。
在 Services 文件夹中创建一个名为 CodeService 的新文件夹,并在 CodeService 文件夹中创建一个名为 CodeStoreService.cs 的新类,此类的内容如下:
此服务尚未完成,我们将再次回到它。
using Microsoft.AspNetCore.Authentication.Cookies;
using OAuth20.Server.Models;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
namespace OAuth20.Server.Services.CodeServce
{
public class CodeStoreService : ICodeStoreService
{
private readonly ConcurrentDictionary<string, AuthorizationCode> _codeIssued = new ConcurrentDictionary<string, AuthorizationCode>();
private readonly ClientStore _clientStore = new ClientStore();
// Here I genrate the code for authorization, and I will store it
// in the Concurrent Dictionary
public string GenerateAuthorizationCode(string clientId, IList<string> requestedScope)
{
var client = _clientStore.Clients.Where(x => x.ClientId == clientId).FirstOrDefault();
if(client != null)
{
var code = Guid.NewGuid().ToString();
var authoCode = new AuthorizationCode
{
ClientId = clientId,
RedirectUri = client.RedirectUri,
RequestedScopes = requestedScope,
};
// then store the code is the Concurrent Dictionary
_codeIssued[code] = authoCode;
return code;
}
return null;
}
public AuthorizationCode GetClientDataByCode(string key)
{
AuthorizationCode authorizationCode;
if (_codeIssued.TryGetValue(key, out authorizationCode))
{
return authorizationCode;
}
return null;
}
public AuthorizationCode RemoveClientDataByCode(string key)
{
AuthorizationCode authorizationCode;
_codeIssued.TryRemove(key, out authorizationCode);
return null;
}
}
}
此类有三个方法:一个用于存储客户端数据,一个用于获取客户端数据,最后一个方法用于从并发字典中删除客户端数据
您可以使用 Dictionary 代替并发字典,但并发字典是线程安全的,如果您想使用 Dictionary,请务必使用 C# lock 关键字锁定该方法,以避免并发问题
在CodeService内部创建一个接口并将其命名为ICodeStoreService.cs
using OAuth20.Server.Models;
using System.Collections.Generic;
namespace OAuth20.Server.Services.CodeServce
{
public interface ICodeStoreService
{
string GenerateAuthorizationCode(string clientId, IList<string> requestedScope);
AuthorizationCode GetClientDataByCode(string key);
AuthorizationCode RemoveClientDataByCode(string key);
}
}
更新 CodeStoreService.cs 类并让它从您最近创建的接口继承
public class CodeStoreService : ICodeStoreService
我们需要在 program.cs 类中注册这个接口
builder.Services.AddSingleton<ICodeStoreService, CodeStoreService>();
在 Model 文件夹中创建名为 CheckClientResult.cs 的新类,如下所示:
namespace OAuth20.Server.Models
{
public class CheckClientResult
{
public Client Client { get; set; }
/// <summary>
/// The clinet is found in my Clients Store
/// </summary>
public bool IsSuccess { get; set; }
public string Error { get; set; }
public string ErrorDescription { get; set; }
}
}
接下来是授权端点,我们需要一个新的服务来负责验证客户端并颁发令牌,并将我们需要的所有信息存储在最近创建的并发字典中。让我们在“
Inside Services”文件夹中创建一个名为 AuthorizeResultService.cs 的新类。
我会从这里慢慢讲到最后,请耐心等待。我会把所有需要验证用户身份的方法都放上去。
这项服务尚未完成,我们稍后再讨论。
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.IdentityModel.Tokens;
using NuGet.Common;
using OAuth20.Server.Common;
using OAuth20.Server.Models;
using OAuth20.Server.OauthRequest;
using OAuth20.Server.OauthResponse;
using OAuth20.Server.Services.CodeServce;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
namespace OAuth20.Server.Services
{
public class AuthorizeResultService : IAuthorizeResultService
{
private static string keyAlg = "66007d41-6924-49f2-ac0c-e63c4b1a1730";
private readonly ClientStore _clientStore = new ClientStore();
private readonly ICodeStoreService _codeStoreService;
public AuthorizeResultService(ICodeStoreService codeStoreService)
{
_codeStoreService = codeStoreService;
}
public AuthorizeResponse AuthorizeRequest(IHttpContextAccessor httpContextAccessor, AuthorizationRequest authorizationRequest)
{
AuthorizeResponse response = new AuthorizeResponse();
if (httpContextAccessor == null)
{
response.Error = ErrorTypeEnum.ServerError.GetEnumDescription();
return response;
}
var client = VerifyClientById(authorizationRequest.client_id);
if (!client.IsSuccess)
{
response.Error = client.ErrorDescription;
return response;
}
if (string.IsNullOrEmpty(authorizationRequest.response_type) || authorizationRequest.response_type != "code")
{
response.Error = ErrorTypeEnum.InvalidRequest.GetEnumDescription();
response.ErrorDescription = "response_type is required or is not valid";
return response;
}
if (!authorizationRequest.redirect_uri.IsRedirectUriStartWithHttps() && !httpContextAccessor.HttpContext.Request.IsHttps)
{
response.Error = ErrorTypeEnum.InvalidRequest.GetEnumDescription();
response.ErrorDescription = "redirect_url is not secure, MUST be TLS";
return response;
}
// check the return url is match the one that in the client store
// check the scope in the client store with the
// one that is comming from the request MUST be matched at leaset one
var scopes = authorizationRequest.scope.Split(' ');
var clientScopes = from m in client.Client.AllowedScopes
where scopes.Contains(m)
select m;
if (!clientScopes.Any())
{
response.Error = ErrorTypeEnum.InValidScope.GetEnumDescription();
response.ErrorDescription = "scopes are invalids";
return response;
}
string nonce = httpContextAccessor.HttpContext.Request.Query["nonce"].ToString();
// Verify that a scope parameter is present and contains the openid scope value.
// (If no openid scope value is present,
// the request may still be a valid OAuth 2.0 request, but is not an OpenID Connect request.)
string code = _codeStoreService.GenerateAuthorizationCode(authorizationRequest.client_id, clientScopes.ToList());
if (code == null)
{
response.Error = ErrorTypeEnum.TemporarilyUnAvailable.GetEnumDescription();
return response;
}
response.RedirectUri = client.Client.RedirectUri + "?response_type=code" + "&state=" + authorizationRequest.state;
response.Code = code;
response.State = authorizationRequest.state;
response.RequestedScopes = clientScopes.ToList();
response.Nonce = nonce;
return response;
}
private CheckClientResult VerifyClientById(string clientId, bool checkWithSecret = false, string clientSecret = null)
{
CheckClientResult result = new CheckClientResult() { IsSuccess = false };
if (!string.IsNullOrWhiteSpace(clientId))
{
var client = _clientStore.Clients.Where(x => x.ClientId.Equals(clientId, StringComparison.OrdinalIgnoreCase)).FirstOrDefault();
if (client != null)
{
if (checkWithSecret && !string.IsNullOrEmpty(clientSecret))
{
bool hasSamesecretId = client.ClientSecret.Equals(clientSecret, StringComparison.InvariantCulture);
if (!hasSamesecretId)
{
result.Error = ErrorTypeEnum.InvalidClient.GetEnumDescription();
return result;
}
}
// check if client is enabled or not
if (client.IsActive)
{
result.IsSuccess = true;
result.Client = client;
return result;
}
else
{
result.ErrorDescription = ErrorTypeEnum.UnAuthoriazedClient.GetEnumDescription();
return result;
}
}
}
result.ErrorDescription = ErrorTypeEnum.AccessDenied.GetEnumDescription();
return result;
}
}
}
在Service文件夹内新建一个接口IAuthorizeResultService.cs,接口内容如下:
using Microsoft.AspNetCore.Http;
using OAuth20.Server.OauthRequest;
using OAuth20.Server.OauthResponse;
namespace OAuth20.Server.Services
{
public interface IAuthorizeResultService
{
AuthorizeResponse AuthorizeRequest(IHttpContextAccessor httpContextAccessor, AuthorizationRequest authorizationRequest);
}
}
我们需要在 program.cs 类中注册这个接口
builder.Services.AddScoped<IAuthorizeResultService, AuthorizeResultService>();
客户端身份验证成功后,我们需要让资源所有者插入他的用户名和密码,因此我们需要在 OauthRequest 文件夹中创建一个名为 OpenIdConnectLoginRequest.cs 的新类:
using System.Collections.Generic;
namespace OAuth20.Server.OauthRequest
{
public class OpenIdConnectLoginRequest
{
public string UserName { get; set; }
public string Password { get; set; }
public string RedirectUri { get; set; }
public string Code { get; set; }
public string Nonce { get; set; }
public IList<string> RequestedScopes { get; set; }
}
}
现在更新 HomeController.cs 类中的 Authorize 端点,如下所示:
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IAuthorizeResultService _authorizeResultService;
private readonly ICodeStoreService _codeStoreService;
public HomeController(IHttpContextAccessor httpContextAccessor, IAuthorizeResultService authorizeResultService,
ICodeStoreService codeStoreService)
{
_httpContextAccessor = httpContextAccessor;
_authorizeResultService = authorizeResultService;
_codeStoreService = codeStoreService;
}
public IActionResult Authorize(AuthorizationRequest authorizationRequest)
{
var result = _authorizeResultService.AuthorizeRequest(_httpContextAccessor, authorizationRequest);
if (result.HasError)
return RedirectToAction("Error", new { error = result.Error });
var loginModel = new OpenIdConnectLoginRequest
{
RedirectUri = result.RedirectUri,
Code = result.Code,
RequestedScopes = result.RequestedScopes,
Nonce = result.Nonce
};
return View("Login", loginModel);
}
当AuthorizeRequest方法返回成功响应时,我们将用户返回到登录视图
在 HomeController.cs 中创建一个名为 Login 的新 Action 方法
[HttpGet]
public IActionResult Login()
{
return View();
}
在 Views/Home 文件夹中创建一个新的 Login.cshtml 文件
@model OAuth20.Server.OauthRequest.OpenIdConnectLoginRequest
@{
ViewData["Title"] = "Login";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<h1>Login - Page</h1>
<div class="row">
@foreach (var i in Model.RequestedScopes)
{
<p>@i</p>
}
<div class="col-12">
<form asp-action="Login" asp-controller="Home" method="post">
<input type="hidden" asp-for="RedirectUri" />
<input type="hidden" asp-for="Code" />
<input type="hidden" asp-for="Nonce" />
@for (int i = 0; i < Model.RequestedScopes.Count; i++)
{
<input type="hidden" asp-for="RequestedScopes[i]" />
}
<div class="col-md-6">
<label>User Name</label>
<input type="text" asp-for="UserName" class="form-control" />
</div>
<div class="col-md-6">
<label>Password</label>
<input type="text" asp-for="Password" class="form-control" />
</div>
<div class="col-md-6">
<input type="submit" value="Login" class="btn btn-primary" />
</div>
</form>
</div>
</div>
在这里,用户将插入他的用户名和密码,但为此我们必须创建一个 POST 版本的登录操作方法,所以让我们在 HomController.cs 类中创建它
但在此之前,我们需要通过向其添加一个名为“UpdatedClientDataByCode”的新方法来更新 CodeStoreService.cs 服务,因此请打开 CodeStoreService.cs 并添加以下方法,不要忘记通过添加方法名称来更新ICodeStoreService接口
// TODO
// Before updated the Concurrent Dictionary I have to Process User Sign In,
// and check the user credienail first
// But here I merge this process here inside update Concurrent Dictionary method
public AuthorizationCode UpdatedClientDataByCode(string key, IList<string> requestdScopes,
string userName, string password = null, string nonce = null)
{
var oldValue = GetClientDataByCode(key);
if (oldValue != null)
{
// check the requested scopes with the one that are stored in the Client Store
var client = _clientStore.Clients.Where(x => x.ClientId == oldValue.ClientId).FirstOrDefault();
if (client != null)
{
var clientScope = (from m in client.AllowedScopes
where requestdScopes.Contains(m)
select m).ToList();
if (!clientScope.Any())
return null;
AuthorizationCode newValue = new AuthorizationCode
{
ClientId = oldValue.ClientId,
CreationTime = oldValue.CreationTime,
IsOpenId = requestdScopes.Contains("openId") || requestdScopes.Contains("profile"),
RedirectUri = oldValue.RedirectUri,
RequestedScopes = requestdScopes,
Nonce = nonce
};
// ------------------ I suppose the user name and password is correct -----------------
var claims = new List<Claim>();
if (newValue.IsOpenId)
{
// TODO
// Add more claims to the claims
}
var claimIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
newValue.Subject = new ClaimsPrincipal(claimIdentity);
// ------------------ ----------------------------------------------- -----------------
var result = _codeIssued.TryUpdate(key, newValue, oldValue);
if (result)
return newValue;
return null;
}
}
return null;
}
该方法的作用是更新并发字典中客户端的HttpContext的Request信息。
现在在 HomeController.cs 中为登录创建 POST 操作方法
[HttpPost]
public async Task<IActionResult> Login(OpenIdConnectLoginRequest loginRequest)
{
// here I have to check if the username and passowrd is correct
// and I will show you how to integrate the ASP.NET Core Identity
// With our framework
var result = _codeStoreService.UpdatedClientDataByCode(loginRequest.Code, loginRequest.RequestedScopes,
loginRequest.UserName, nonce: loginRequest.Nonce);
if (result != null)
{
loginRequest.RedirectUri = loginRequest.RedirectUri + "&code=" + loginRequest.Code;
return Redirect(loginRequest.RedirectUri);
}
return RedirectToAction("Error", new { error = "invalid_request" });
}
在 Views/Home 中创建一个新的 Error.cshtml 文件并将以下代码粘贴到其中
@model string
@{
ViewData["Title"] = "Error";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<h1>Error</h1>
用户成功登录后,我们将他/她返回到返回的 URI,并通过 ClientStore.cs 中的 URI 进行验证,此后,AddOpenIdConnect(来自客户端应用程序)扩展对令牌端点进行软HTTP 调用,您还记得我们在哪里保护这个端点的值吗?如果您检查 DiscoveryEndpointController.cs 文件,您会发现这个端点的值被分配给 token_endpoint 属性,如下所示:
token_endpoint = "https://localhost:7275/Home/Token",
好的,到此我们完成了授权的过程,下一步是获取TOKEN
颁发身份令牌和访问令牌
当我们使用这些令牌(id_token 和 access_token)时,我们需要新的类来使我们的旅程变得非常轻松
在 OauthRequest 文件夹中创建一个名为 TokenRequest.cs 的新类,如下所示:
namespace OAuth20.Server.OauthRequest
{
public class TokenRequest
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string Code { get; set; }
public string GrantType { get; set; }
public string RedirectUri { get; set; }
public string CodeVerifier { get; set; }
}
}
然后,在 Models 文件夹中创建一个名为 TokenTypeEnum.cs 的新枚举
using System.ComponentModel;
namespace OAuth20.Server.Models
{
public enum TokenTypeEnum : byte
{
[Description("Bearer")]
Bearer
}
}
最后但同样重要的是,在 OauthResponse 文件夹中创建一个名为 TokenResponse.cs 的新类,如下所示:
using OAuth20.Server.Common;
using OAuth20.Server.Models;
namespace OAuth20.Server.OauthResponse
{
public class TokenResponse
{
/// <summary>
/// Oauth 2
/// </summary>
public string access_token { get; set; }
/// <summary>
/// OpenId Connect
/// </summary>
public string id_token { get; set; }
/// <summary>
/// By default is Bearer
/// </summary>
public string token_type { get; set; } = TokenTypeEnum.Bearer.GetEnumDescription();
/// <summary>
/// Authorization Code. This is always returned when using the Hybrid Flow.
/// </summary>
public string code { get; set; }
// For Error Details if any
public string Error { get; set; } = string.Empty;
public string ErrorUri { get; set; }
public string ErrorDescription { get; set; }
public bool HasError => !string.IsNullOrEmpty(Error);
}
}
通过添加以下新方法更新 AuthorizeResultService.cs 类:
public TokenResponse GenerateToken(IHttpContextAccessor httpContextAccessor)
{
TokenRequest request = new TokenRequest();
request.CodeVerifier = httpContextAccessor.HttpContext.Request.Form["code_verifier"];
request.ClientId = httpContextAccessor.HttpContext.Request.Form["client_id"];
request.ClientSecret = httpContextAccessor.HttpContext.Request.Form["client_secret"];
request.Code = httpContextAccessor.HttpContext.Request.Form["code"];
request.GrantType = httpContextAccessor.HttpContext.Request.Form["grant_type"];
request.RedirectUri = httpContextAccessor.HttpContext.Request.Form["redirect_uri"];
var checkClientResult = this.VerifyClientById(request.ClientId, true, request.ClientSecret);
if (!checkClientResult.IsSuccess)
{
return new TokenResponse { Error = checkClientResult.Error, ErrorDescription = checkClientResult.ErrorDescription };
}
// check code from the Concurrent Dictionary
var clientCodeChecker = _codeStoreService.GetClientDataByCode(request.Code);
if (clientCodeChecker == null)
return new TokenResponse { Error = ErrorTypeEnum.InvalidGrant.GetEnumDescription() };
// check if the current client who is one made this authentication request
if (request.ClientId != clientCodeChecker.ClientId)
return new TokenResponse { Error = ErrorTypeEnum.InvalidGrant.GetEnumDescription() };
// TODO:
// also I have to check the rediret uri
// Now here I will Issue the Id_token
JwtSecurityToken id_token = null;
if (clientCodeChecker.IsOpenId)
{
// Generate Identity Token
int iat = (int)DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1)).TotalSeconds;
string[] amrs = new string[] { "pwd" };
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(keyAlg));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>()
{
new Claim("sub", "856933325856"),
new Claim("given_name", "Mohammed Ahmed Hussien"),
new Claim("iat", iat.ToString(), ClaimValueTypes.Integer), // time stamp
new Claim("nonce", clientCodeChecker.Nonce)
};
foreach (var amr in amrs)
claims.Add(new Claim("amr", amr));// authentication method reference
id_token = new JwtSecurityToken("https://localhost:7275", request.ClientId, claims, signingCredentials: credentials,
expires: DateTime.UtcNow.AddMinutes(
int.Parse("5")));
}
// Here I have to generate access token
var key_at = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(keyAlg));
var credentials_at = new SigningCredentials(key_at, SecurityAlgorithms.HmacSha256);
var claims_at = new List<Claim>();
var access_token = new JwtSecurityToken("https://localhost:7275", request.ClientId, claims_at, signingCredentials: credentials_at,
expires: DateTime.UtcNow.AddMinutes(
int.Parse("5")));
// here remoce the code from the Concurrent Dictionary
_codeStoreService.RemoveClientDataByCode(request.Code);
return new TokenResponse
{
access_token = new JwtSecurityTokenHandler().WriteToken(access_token),
id_token = id_token != null ? new JwtSecurityTokenHandler().WriteToken(id_token) : null,
code = request.Code
};
}
方法很简单,验证Client然后新建id_token然后access_token
一定要在IAuthorizeResultService.cs接口中添加签名这个方法
回到 HomeController.cs 类,这里是 token endint 的签名
public JsonResult Token()
{
var result = _authorizeResultService.GenerateToken(_httpContextAccessor);
if (result.HasError)
return Json("0");
return Json(result);
}
回到我们在本文开头创建的客户端应用程序。在 Views/Home 目录下创建一个 Index.cshtml 文件,文件内容如下:
@using Microsoft.AspNetCore.Authentication
@{
ViewData["Title"] = "Index";
Layout = "~/Views/_Layout.cshtml";
}
<div class="col-12">
<div class="card">
<div class="card-header">
<h1>Tokens Resut - For PlatformNet6 Client</h1>
</div>
<div class="card-body">
@if (User.Identity.IsAuthenticated)
{
<h2>User is Authenticated</h2>
<p>
<ul>
@foreach (var claim in User.Claims)
{
<li><strong> @claim.Type:</strong> @claim.Value</li>
}
<li><strong>Access Token: </strong>@await Context.GetTokenAsync("access_token")</li>
<li><strong>Identity Token: </strong>@await Context.GetTokenAsync("id_token")</li>
</ul>
</p>
}
else
{
<h2>ohhhh, u being Unauthenticated</h2>
}
</div>
</div>
</div>
现在运行您的授权服务器,然后运行客户端应用程序
,您将看到如下结果:
好了,我们搞定了,现在我们需要改进授权服务器代码和应用程序结构。这是下一步。下篇文章再见。
同样,您可以从我的 Github 仓库下载完整的源代码,欢迎随时添加拉取请求。
尽情享受吧!