在 ASP.NET Core 6.0 中构建自己的 OAuth 2.0 服务器和 OpenId Connect 提供程序

2025-06-07

在 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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

通过使用 Nuget 包安装此库:

Microsoft.AspNetCore.Authentication.OpenIdConnect
Enter fullscreen mode Exit fullscreen mode

打开 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();
Enter fullscreen mode Exit fullscreen mode

在上面的代码中,我使用了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
Enter fullscreen mode Exit fullscreen mode

如果你查看规范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; }
    }
}
Enter fullscreen mode Exit fullscreen mode

规范规定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; }
    }
}
Enter fullscreen mode Exit fullscreen mode

该类最重要的属性是:

  • 发行者(是身份提供者的域名)
  • 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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

该Action方法的返回类型为josn result,该端点的名称在HttpGet属性内部修饰

 [HttpGet("~/.well-known/openid-configuration")]
Enter fullscreen mode Exit fullscreen mode

确保将端点的端口更改为 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;
                },
            };
        });
Enter fullscreen mode Exit fullscreen mode

到目前为止,我们已准备好授权服务器,以便接受来自任何客户端的调用。
让我们打破僵局,创建一个通用的 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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

我不会创建具有字符串类型 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
    }
}
Enter fullscreen mode Exit fullscreen mode

返回 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() };
    }
}
Enter fullscreen mode Exit fullscreen mode

有关此类的更多详细信息,请参阅规范: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"
            }
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

ClientUriRedirectUri应该指向我们在本文开头创建的 PlatformNet6 应用程序(客户端)。请务必将 Port 替换为 Visual Studio 为您创建的端口(只需替换端口。ClientName以及ClientIdClientSecretGrantType应该与您在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"
Enter fullscreen mode Exit fullscreen mode

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; }
    }
}
Enter fullscreen mode Exit fullscreen mode

那么,让我们深入研究并创建授权端点。

在 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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

仔细看的话,我使用了 IHttpContextAccessor 接口,它提供了对当前 HttpContext 请求的访问。
你需要在 Program.cs 文件中注册这个接口,如下所示:

builder.Services.AddHttpContextAccessor();
Enter fullscreen mode Exit fullscreen mode

在 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
    }
}
Enter fullscreen mode Exit fullscreen mode

有关错误类型的更多详细信息,请参阅 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

从 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; }
    }
}
Enter fullscreen mode Exit fullscreen mode

这个类的名称来自生成代码步骤,我将把这个信息存储在 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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

此类有三个方法:一个用于存储客户端数据,一个用于获取客户端数据,最后一个方法用于从并发字典中删除客户端数据

您可以使用 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

更新 CodeStoreService.cs 类并让它从您最近创建的接口继承

public class CodeStoreService : ICodeStoreService
Enter fullscreen mode Exit fullscreen mode

我们需要在 program.cs 类中注册这个接口

builder.Services.AddSingleton<ICodeStoreService, CodeStoreService>();
Enter fullscreen mode Exit fullscreen mode

在 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; }
    }
}
Enter fullscreen mode Exit fullscreen mode

接下来是授权端点,我们需要一个新的服务来负责验证客户端并颁发令牌,并将我们需要的所有信息存储在最近创建的并发字典中。让我们在“
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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

在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);
    }
}
Enter fullscreen mode Exit fullscreen mode

我们需要在 program.cs 类中注册这个接口

builder.Services.AddScoped<IAuthorizeResultService, AuthorizeResultService>();
Enter fullscreen mode Exit fullscreen mode

客户端身份验证成功后,我们需要让资源所有者插入他的用户名和密码,因此我们需要在 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; }
    }
}
Enter fullscreen mode Exit fullscreen mode

现在更新 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);
        }
Enter fullscreen mode Exit fullscreen mode

AuthorizeRequest方法返回成功响应时,我们将用户返回到登录视图

在 HomeController.cs 中创建一个名为 Login 的新 Action 方法

    [HttpGet]
        public IActionResult Login()
        {
            return View();
        }
Enter fullscreen mode Exit fullscreen mode

在 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>
Enter fullscreen mode Exit fullscreen mode

在这里,用户将插入他的用户名和密码,但为此我们必须创建一个 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;
        }
Enter fullscreen mode Exit fullscreen mode

该方法的作用是更新并发字典中客户端的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" });
        }
Enter fullscreen mode Exit fullscreen mode

在 Views/Home 中创建一个新的 Error.cshtml 文件并将以下代码粘贴到其中

@model string
@{
    ViewData["Title"] = "Error";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>Error</h1>
Enter fullscreen mode Exit fullscreen mode

用户成功登录后,我们将他/她返回到返回的 URI,并通过 ClientStore.cs 中的 URI 进行验证,此后,AddOpenIdConnect(来自客户端应用程序)扩展对令牌端点进行软HTTP 调用,您还记得我们在哪里保护这个端点的值吗?如果您检查 DiscoveryEndpointController.cs 文件,您会发现这个端点的值被分配给 token_endpoint 属性,如下所示:

token_endpoint = "https://localhost:7275/Home/Token",
Enter fullscreen mode Exit fullscreen mode

好的,到此我们完成了授权的过程,下一步是获取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; }
    }
}
Enter fullscreen mode Exit fullscreen mode

然后,在 Models 文件夹中创建一个名为 TokenTypeEnum.cs 的新枚举

using System.ComponentModel;

namespace OAuth20.Server.Models
{
    public enum TokenTypeEnum : byte
    {
        [Description("Bearer")]
        Bearer
    }
}
Enter fullscreen mode Exit fullscreen mode

最后但同样重要的是,在 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

通过添加以下新方法更新 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
            };
        }
Enter fullscreen mode Exit fullscreen mode

方法很简单,验证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);
        }
Enter fullscreen mode Exit fullscreen mode

回到我们在本文开头创建的客户端应用程序。在 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>
Enter fullscreen mode Exit fullscreen mode

现在运行您的授权服务器,然后运行客户端应用程序
,您将看到如下结果:

图片描述

好了,我们搞定了,现在我们需要改进授权服务器代码和应用程​​序结构。这是下一步。下篇文章再见。
同样,您可以从我的 Github 仓库下载完整的源代码,欢迎随时添加拉取请求。
尽情享受吧!

文章来源:https://dev.to/mohammedahmed/build-your-own-oauth-20-server-and-openid-connect-provider-in-aspnet-core-60-1g1m
PREV
免费电子书:工程师在工作中获得更多认可的指南
NEXT
如何将 Docker 镜像大小从 1.43 GB 缩减到 22.4 MB