认证与授权(STS)

OAuth2 vs OIDC

下图展示了目前比较主流的产品架构。 在应对复杂的业务需求时,我们往往需要同时提供多种类型的“系统接口”给使用者。 例如目前比较主流的:原生 App 、H5 的单页应用、传统的 MVC 网站、智能信息采集的单片机应用等。

sts-token

为了统一解决不同种类的产品对系统认证、鉴权的需求。 IETF 组织在 OAuth 协议的基础上提出了 OAuth2 协议, 由于众多的公司加入支持并实现了 OAuth2 , 所以, OAuth2 逐渐的成为互联网资源保护的标准协议。
OpenID Connect (OIDC) 扩展了 OAuth2 授权协议,使其也可用作身份验证协议。 可以使用 OIDC 通过一个称作 Token 的安全令牌在支持 OAuth 的应用程序之间启用单一登录 (SSO) 来解决具有可视化页面的应用系统需要关心用户信息相关的认证和授权而来。

简单理解如下:

  • OAuth2 设计出来是为了解决 client -> client 之间调用的访问权限问题。

  • OIDC 设计出来是为了解决 user -> client 之前的访问权限问题。

代码层面的区别: OAuth2 直接对 token 验签后根据 token 中的信息作为鉴权依据(一般,我们只验证 scope 作用域)。
而 OIDC 则会根据当前的 userId 调用 UserInfo 接口获得更多的用户侧信息,来帮组完成鉴权。

STS 使用的开源组件

组件

说明

作用

官方文档

IdentityServer4

基于 Asp.Net Core 实现了 OIDC 和 OAuth2 协议的一套框架

用于颁发 token 并对 token 进行验证

官方文档

IdentityServer.Admin

IdentityServer4 + Asp.Net Core Identity 的一套管理系统

用于提供可视化界面对资源、权限等进行配置

GitHub

IdentityModel

Client 使用 OIDC 和 OAuth2 的一套 Asp.Net Core 组件

对 IdentityServer4 提供的 Api 和 oauth2/oidc 协议行为进行了封装,便于 client 使用 oauth2/oidc 协议

官方文档

STS 相关概念

名词

说明

STS

Security Token Service

User

用户

Client

应用程序

Resources

    ∟ Identity Resources

代表用户数据的一类资源,如手机号、邮箱

    ∟ API Resources

将一组 Api Scope 组合起来成为一个资源

    ∟ API Scopes

抽象概念的一个 Api 范围

Token

    ∟ Identity Token

    ∟ Access Token

    ∟ Refresh Token

小技巧

当想要往 id_token 中添加 claim 时,就需要给 client 增加 IdentityResource
当想要往 access_token 中添加 claim 时,就需要给 client 添加 ApiResource (或 ApiScope)

STS 工作原理

典型的单页应用的授权时序:

oidc

Json Web Token(JTW)

JWT 包含三个部分: Header.Payload.Signature 。内容为明文,base64 解码后可见。

JWT 生成的算法:

JWTString=Base64(Header).Base64(Payload).HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload),secret)

jwt

Token 获取

在 STS 服务中提供了一个 Token Endpoint 的 api 。我们根据我们的 client 开通的授权类型使用此接口来获取 token。
下面,我们以最常见的 client_credentials 类型授权来展示如何通过 api 来获取 token

POST /connect/token HTTP/1.1
Host: sts.demo.com
Content-Type: application/x-www-form-urlencoded

client_id=mvc&client_secret=4Mse0W5b&grant_type=client_credentials&scope=api.access

小技巧

目前我们的 STS 支持以下 GrantType:

  • password

  • authorization_code

  • client_credentials

  • refresh_token

  • urn:ietf:params:oauth:grant-type:device_code

DotNet 认证管道

在 DotNet 的认证授权中, scheme 是一个非常重要的概念。 它由 Handler(处理程序) + Options(配置) 组成。 Handler 的主要作用是从 http 上下文中,解析自己的认证信息,并将解析后的信息放入到 ClaimsPrincipal 中。 Options 则是对 Handler 的配置。

例如:

  • cookie 认证的 scheme 由 CookieAuthenticationHandler + CookieAuthenticationOptions 组成。 它从 http 的 cookie 中解析用户信息。

  • bearer 认证的 scheme 由 JwtBearerHandler + JwtBearerOptions 组成。 它从 http 的 auth header 中解析 jwt token 信息。

另外,Handler 还需要处理 http 上下午无认证、和无权限的具体实现。比如常见的返回 401、403 这样的状态码。

在 http 的处理管道中,认证的 middleware 会先执行。它通过全局指定的默认 scheme 来进行认证,后续授权的 middleware 开始处理 http 请求。
在授权阶段,框架会先扫描目标 api 上的授权方案。 如果是 [AllowAnonymous] 则直接放行。否则,会根据 api 授权配置挨个执行指定的认证 scheme 来合并用户信息。

大致流程如下图,其中比较重要的是两个接口 IAuthenticationHandler , IAuthorizationHandlerProvider 。 他们分别实现了授权和认证过程中,具体从 http 请求体中需要获取哪些数据,并通过这些数据来验证身份和检验权限。

dotnet-security

DotNet 认证授权方式

几种常用的 DotNet 认证授权方式:

// Simple authorization
[Authorize]
public class AccountController : Controller
{
    [AllowAnonymous]
    public ActionResult Action()
    {
    }
}

// Role-based authorization
// hr.manager role or the Finance role.
[Authorize(Roles = "hr.manager,finance")]
public class SalaryController : Controller
{
    [Authorize(Roles = "hr.manager")]
    public ActionResult Action()
    {
    }
}

// both the PowerUser and ControlPanelUser role
[Authorize(Roles = "power.user")]
[Authorize(Roles = "control.panel.user")]
public class UserController : Controller
{
    public ActionResult Action()
    {
    }
}

// Claims-based authorization +  Policy-based authorization
builder.Services.AddAuthorization(options =>
{
   options.AddPolicy("user.photo.full", policy
        => policy.RequireClaim("permission","user.photo.delete", "user.photo.add", "user.photo.edit")
   );
});

[Authorize(Policy = "user.photo.full")]
public class UserPhotoController : Controller
{
    public ActionResult Action()
    {
    }
}

// func to fulfill a policy
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("BadgeEntry", policy =>
        policy.RequireAssertion(context =>
            context.User.HasClaim(c =>
                    (c.Type == "BadgeId" || c.Type == "TemporaryBadgeId")
                    && c.Issuer == "https://microsoftsecurity"
            )
        )
    );
});

WebApi 集成 STS

重要

注意 UserInfo EndPoint 是为了 client 设计,而非 OpenApi 设计。
为了提升性能不建议在每次认证处都去获取 UserInfo。仅对明文的 Token 做解析。

  1. 添加引用

dotnet add package IdentityModel
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
  1. appsettings.json 增加配置

{
  "STS": {
    "Authority": "https://sts.demo.com",
    "ClientId": "mvc",
    "ClientSecret": "4Mse0W5b"
  }
}
public class STSConfig
{
    public const string SectionName = "STS";
    public string Authority { get; set; }
    public string ClientId { get; set; }
    public string ClientSecret { get; set; }
}
  1. Startup.cs 中配置 STS 并增加授权策略

public void ConfigureServices(IServiceCollection services)
{
     // 清空 dotnet core identity 默认的 ClaimType
     JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
     var stsConfig = Configuration.GetSection(STSConfig.SectionName).Get<STSConfig>();
     services.AddAuthentication("Bearer")
             .AddJwtBearer("Bearer", options =>
             {
                 options.Authority = stsConfig.Authority;
                 options.RequireHttpsMetadata = false;
                 options.TokenValidationParameters = new TokenValidationParameters
                 {
                     ValidateAudience = false,
                     NameClaimType = IdentityModel.JwtClaimTypes.Name,
                     RoleClaimType = IdentityModel.JwtClaimTypes.Role
                 };
             });

     services.AddAuthorization(options =>
     {
         options.AddPolicy("user.photo.delete", policy =>
         {
             policy.RequireAuthenticatedUser();
             policy.RequireClaim("permission", "user.photo.delete");
         });
     });
}

public void Configure(IApplicationBuilder app)
{
     app.UseAuthentication();
     app.UseAuthorization();
}
  1. Controller 增加授权

 [ApiController]
 public class MessageController : ControllerBase
 {
     [HttpDelete]
     [Route("api/v1/user/photo/del")]
     [Authorize(Roles = "user.admin")]
     [Authorize(Policy = "user.photo.delete")]
     public Result<string> Del()
     {
         var account = User.FindFirst("name");
         return Result.Ok("");
     }
 }

服务之间相互之间请求

小技巧

在复杂系统中,Api Scope 会非常的庞大,但我们会发现 token 中的 scope 列表总是很小的。 这是因为,当系统之间相互请求时,发起方必然是知道目标 api 需要的权限。 所以,当他向 STS 请求 token 时,可以指定我要获取的 scope 列表。而不用获得自己所被赋予的全部 scope 这也是我们服务之间相互调用的推荐的做法。

Api vs OpenApi (不同的客户端类型)

我们先来看一下,下面两个相同功能的 Api 。

[ApiController]
public class UserController : ControllerBase
{
    // 1
    [LoadUserInfo]
    [HttpGet]
    [Route("api/v1/user")]
    [Authorize]
    public Result<UserInfo> Get()
    {
        var account = User.FindFirst("name");
        // ... query info
        return Result.Ok(new UserInfo());
    }

    // 2
    [HttpGet]
    [Route("api/v1/user/{account}")]
    [Authorize]
    public Result<string> Get(string account)
    {
        // query user
        return Result.Ok(new UserInfo());
    }
}
  • api/v1/user 一般是直接给前后端分离的页面使用的 api,这种 api 在权限控制上,一般更多的会关注操作人的信息。此时,传递过来的 token 一般是一个以 oidc 方式生成的 token 。

  • api/v1/user/{account} 一般是开放平台给第三方的应用系统提供的 api 。应该请求发起方是一个后台服务,他可能已经没有用户信息的上下文。此时,他传递的 token 一般是一个 client_credentials 认证方式的 token。

所以,一般当服务调用服务时,我们一般会出现 2 中获取 token 的情况

  1. 目标接口是一个 OpenApi , 支持我们只需要使用应用自己的 ClientId ClientSecret 去请求访问权限即可

 [ApiController]
 public class UserController : ControllerBase
 {
     [HttpGet]
     [Route("ct")]
     public async Task<IActionResult> ClientToken()
     {
         var stsConfig = _configuration.GetSection(STSConfig.SectionName).Get<STSConfig>();
         var client = new HttpClient();
         var disco = await client.GetDiscoveryDocumentAsync(stsConfig.Authority);
         if (disco.IsError)
         {
             return Problem(disco.Error);
         }

         // 请求 client 访问 token
         var tokenResponse = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
         {
             Address = disco.TokenEndpoint,
             ClientId = stsConfig.ClientId,
             ClientSecret = stsConfig.ClientSecret,
             Scope = "api.access"
         });

         if (tokenResponse.IsError)
         {
             Console.WriteLine();
             return Problem(tokenResponse.Error);
         }

         // accessToken
         var clientAccessToken = tokenResponse.AccessToken;

         return new JsonResult(tokenResponse.Json);
     }
 }

token 解析后的内容如下:

{
  "nbf": 1657808214,
  "exp": 1657811814,
  "iss": "https://sts.demo.com",
  "client_id": "mvc",
  "iat": 1657808214,
  "scope": ["permissions", "api.access"]
}
  1. 目标接口是一个需要会话的 Api,此时我们就需要带着 oidc 的 accessToken 去换一个代理 token

 [ApiController]
 public class UserController : ControllerBase
 {
     [HttpGet]
     [Route("delegation")]
     [Authorize]
     public async Task<IActionResult> DelegationToken()
     {
         var account = User.FindFirst("name");
         var stsConfig = _configuration.GetSection(STSConfig.SectionName).Get<STSConfig>();
         var client = new HttpClient();
         var disco = await client.GetDiscoveryDocumentAsync(stsConfig.Authority);
         if (disco.IsError)
         {
             return Problem(disco.Error);
         }

         // 获取 oidc 的 accessToken
         var userAccessToken = await this.HttpContext.GetTokenAsync("Bearer", "access_token");
         // 请求代理 token
         var tokenResponse = await client.RequestTokenAsync(new TokenRequest
         {
             Address = disco.TokenEndpoint,
             GrantType = "delegation",

             ClientId = stsConfig.ClientId,
             ClientSecret = stsConfig.ClientSecret,

             Parameters ={
                 { "scope", "api.access" },
                 { "token", userAccessToken }
             }
         });

         if (tokenResponse.IsError)
         {
             Console.WriteLine();
             return Problem(tokenResponse.Error);
         }
         // 代理的 accessToken
         var delegationAccessToken = tokenResponse.AccessToken;

         return new JsonResult(tokenResponse.Json);
     }
 }
{
  "nbf": 1657808766,
  "exp": 1657812366,
  "iss": "https://sts.demo.com",
  "client_id": "mvc",
  "sub": "cdc7b180-4a30-4b18-a55f-44a289701730",
  "auth_time": 1657808766,
  "idp": "local",
  "role": ["sys.admin", "msg.admin"],
  "name": "liaoyunxiang",
  "iat": 1657808766,
  "scope": ["permissions", "api.access"],
  "amr": ["delegation"]
}

小技巧

client token 和 delegation token 最明显的区别是代理的 token 中包含了用户调用的信息。 这样 token 中的 client_id 在请求从 A -> B -> C 即发生了相应的变化,同时还保留了原始的用户信息。

MVC 集成 STS

dotnet mvc 的集成很类似于上面的 webapi 对接。区别仅在于, mvc 站点需要使用 oidc 的方式完成 STS 对接。

ConfigureServices in Startup :

using System.IdentityModel.Tokens.Jwt;

public void ConfigureServices(IServiceCollection services)
{
    JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
    var stsConfig = Configuration.GetSection(STSConfig.SectionName).Get<STSConfig>();
    services.AddAuthentication(options =>
            {
                options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
            })
            .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
            .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
            {
                options.Authority = stsConfig.Authority;

                options.ClientId = stsConfig.ClientId;
                options.ClientSecret = stsConfig.ClientSecret;
                options.ResponseType = "code";

                options.SaveTokens = true;
                options.Scope.Add("profile");
                options.GetClaimsFromUserInfoEndpoint = true;
            });

    services.AddAuthorization(options =>
    {
        options.AddPolicy("msg.delete", policy =>
        {
            policy.RequireAuthenticatedUser();
            policy.RequireClaim("permission", "A04_MSG_Delete");
        });
    });
}

同一项目同时使用多种认证方式

以下以一个 webapi 项目中集成了 hangfire 的 dashboard 为示例,展示在同一个项目中使用多种认证方式来满足不同的认证授权需要。
在这个场景中,我们的 API 需要支持 Oauth2 认证,而 hangfire 的 dashboard 页面则需要支持 oidc 认证以满足我们在 sts 中对特定页面进行授权。

  1. 添加依赖

    # 增加依赖
    dotnet add package Hangfire.AspNetCore
    dotnet add package HangFire.Core
    dotnet add package Hangfire.MemoryStorage.Core
    dotnet add package IdentityModel
    dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
    dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
    
  2. 添加多种用于认证的 scheme

     1// 添加多种用于认证的 scheme
     2var builder = WebApplication.CreateBuilder(args);
     3builder.Services
     4        .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
     5        // 用于 hangfire dashboard 页面的系统认证
     6        .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
     7        {
     8            options.Events.OnRedirectToAccessDenied = context =>
     9            {
    10                context.Response.StatusCode = 403;
    11                return Task.CompletedTask;
    12            };
    13
    14            options.ForwardChallenge = OpenIdConnectDefaults.AuthenticationScheme;
    15            // options.TicketDataFormat = null; !
    16        })
    17        // 用于标准 OpenApi 认证
    18        .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
    19        {
    20            options.Authority = "https://sts.demo.com";
    21            options.RequireHttpsMetadata = false;
    22            options.TokenValidationParameters = new TokenValidationParameters
    23            {
    24                ValidateAudience = false,
    25                NameClaimType = IdentityModel.JwtClaimTypes.Name,
    26                RoleClaimType = IdentityModel.JwtClaimTypes.Role
    27            };
    28        })
    29        // 用于 hangfire dashboard 页面的 oidc 认证
    30        .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
    31        {
    32            options.Authority = "https://sts.demo.com";
    33
    34            options.ClientId = "mvc";
    35            options.ClientSecret = "cdc7b180-4a30-4b18-a55f-44a289701730";
    36            options.ResponseType = "code";
    37
    38            options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    39
    40            options.Scope.Add("profile");
    41            options.Scope.Add("roles");
    42            options.GetClaimsFromUserInfoEndpoint = true;
    43        });
    

    重要

    这里使用 cookie 认证用于 hangfire dashboard 页面的系统认证。
    当认证通过后会在 cookie 中添加健为 .AspNetCore.Cookies 的加密数据来存储用户信息。
    dotnet 使用 Data Protection 来进行 cookie 的加密。在分布式部署时,需要注意 Data Protection 加密 key 的共享问题。
    可以通过设置 options.TicketDataFormat 来简单实现加密算法,绕过 Data Protection 配置。

    The TicketDataFormat is used to protect and unprotect the identity and other properties which are stored in the cookie value. If not provided one will be created using DataProtectionProvider

  3. 增加 hangfire dashboard 的授权策略

    1builder.Services.AddAuthorization(cfg =>
    2{
    3    cfg.AddPolicy("Hangfire", cfgPolicy =>
    4    {
    5        cfgPolicy.AddRequirements().RequireAuthenticatedUser().RequireRole("hangfire.admin");
    6        cfgPolicy.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme);
    7    });
    8});
    
  4. 增加 hangfire dashboard 的授权配置

    1app.MapHangfireDashboardWithAuthorizationPolicy("Hangfire");
    

通过以上配置, hangfire dashboard 页面只能是具有 hangfire.admin 角色的用户才能访问。

集成注意事项

  1. 所有的 Client 都需要在 STS 中先开通,并获得 ClientIdClientSecret

  2. 所有的 OIDC 的应用,都只有 1 种 token 获取方式: Grant Types : code

  3. 所有的 OAuth2 的应用,都有 2 种 token 获取方式: Grant Types : client_credentials delegation

  4. 所有的 Client 获取的 access_token 默认都是 3600s (1 小时) 的过期时间, oidc 类型的都需要自行处理 token 过去自动刷新的问题。

  5. 所有的 Client 获取的 refresh_token 默认都是 2592000s (30 天) 的过期时间,默认可以实现 30 天内免登陆。 refresh_token 都是一次性使用,再次获取 token 时,会拿到新的 refresh_token 。

  6. Client 命名规则: spa.message.devops , mvc.message.admin , api.message.feishu

  7. Scope 命名规则: api.access , message.msg.delete , bpm.process.read_write

Token 撤销

首先,STS 的 access_token 采用的是自包含的 jwt 类型,这种类型的 token 在颁发后是不能被撤销的
所以,为了更好的控制 access_token 的安全和权限更新率。一般的做法是缩短 access_token 的有效时间。 在我们需要对已经颁发的 access_token 撤销时,最好方式是使用 Revocation Endpointrefresh_token 进行撤销。 这样 access_token 只在较短一段时间内有效,当再次使用 refresh_token 进行续约时,refresh_token 已被撤销。 客户端就需要重新获取新的 access_token 。

Sign-out

首先,需要明确一点, Oidc 、 OAuth2 本身设计种并没有我们常见的 Session 管理。 IdentityServer4 仅针对 Oidc 类型的授权应用提供了 EndSession EndPoint 来帮助实现类似于单点登录后的统一登出的功能。

注意

目前还不支持,后台管理员管理人员 Session 。