在 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 仓库下载完整的源代码,欢迎随时添加拉取请求。
 尽情享受吧!
 后端开发教程 - Java、Spring Boot 实战 - msg200.com
            后端开发教程 - Java、Spring Boot 实战 - msg200.com
          




