asp.net core 接入 oidc 服务端

工作流程如下,客户端通过登录服务器的 oidc 接口登录后获得 access_token, 使用该 token 访问 api 服务, api 服务到登录服务器验证 token 的合法性,并返回数据。

工作方式分以下几种

登录服务器一般返回如下内容

  • 短时间内有效的 AccessToken,可能为 jwt 格式,也可能配置为非 jwt 的非透明 token
  • 包括用户信息的也短时间内有效的 IdentityToken
  1. Authorization: Bearer 传入登录服务端返回 jwt 格式的 IdentityToken, api 服务器接收到的 token 到登录服务器验证通过后解码出 payload, 将数据填充 user 模型, 在工作流程中验证 token 的各种权限
    • 验证流程指到登录服务器的 .well-known/openid-configuration 获取签名对 token 进行签名验证
    • 一般 identity_token 不应该作为 Authorization: Bearer 发送给 API, 只在特殊情况下从简处理
  2. Authorization: Bearer 登录服务端直接返回 jwt 格式的 access_token , api 服务器接收到的 token 验证通过后解码出 payload, 将数据直接填充 user 模型, 在工作流程中验证 token 的各种权限
    • 验证流程指到登录服务器的 .well-known/openid-configuration 获取签名对 token 进行签名验证
    • 与使用上上面的 IdentityToken 的区别是, IdentityToken 内容比较丰富,包括的内容会比较多,但是可能会传递一个庞大的 Bearer。
    • access_token 比较短,可能只包含用户 id 等很少的用户信息, 但可以使用它从登录服务器上获取用户详细信息, 而 IdentityToken 无法用来获取用户详细信息
  3. Authorization: Bearer 登录服务器返回非透明 access_token (非 jwt 格式), 与 jwt 格式的 access_token 的区别是 非透明 access_token 非 jwt 格式, api 服务器必须到登录服务器获取用户详细信息, 但是它是最短的。
    • 验证流程指到登录服务器的 /introspection 接口调用并成功并返回用户数据,然后填充到 user 模型中
    • 比 jwt 格式的 access_token 还短,但是必须到登录服务器获取用户详细信息。因为上面的 access_token 是 jwt 格式的,包括了部分用户信息

登录验证方式

下面演示了 2 种登录验证试

  1. 直接传入 jwt 格式的 token , 可以是 jwt 格式 access_token 也可以直接使用 identity_token, 下面例子中的 'Authelia' 策略
  2. 直接传入非透明格式的 access_token (非 jwt 格式), 下面例子中的 'Authelia_Introspection' 方式
  3. 因为使用多个策略, 所以需要动态选择当前传入的 token 使用哪个处理策略, 下面例子中的 'JWT_Selector' 策略

代码

下面分为三个部分
.AddPolicyScheme("JWT_Selector") 动态选择策略
AddOAuth2Introspection("Authelia_Introspection") 内省模式,处理非透明 access_token
.AddJwtBearer("Authelia") jwt token 方案


// 分布式缓存,Microsoft.Extensions.Caching.Hybrid
builder.Services.AddHybridCache(_ => { });

builder.Services.AddAuthentication(options =>
    {
        # 指定使用 JWT_Selector 策略
        options.DefaultAuthenticateScheme = "JWT_Selector";
        options.DefaultChallengeScheme = "JWT_Selector";
    })
    .AddPolicyScheme("JWT_Selector", "选择验证方案", options =>
    {
        // 动态选择验证方案
        options.ForwardDefaultSelector = context =>
        {
            // 1. 取出 bearer token 待处理
            var authorization = context.Request.Headers.Authorization.ToString();

            // 2. 如果 token 中包含 authelia_,则使用非透明 token 验证
            if (!string.IsNullOrWhiteSpace(authorization) && authorization.Contains("authelia_"))
            {
                // 这里使用 authelia 的非透明 token 方案
                return "Authelia_Introspection";
            }

            // 3. 如果 token 中包含 something_special_for_ms,则使用微软文件验证, 这里未完成,以后再说
            if (!string.IsNullOrWhiteSpace(authorization) && authorization.Contains("something_special_for_ms"))
            {
                // 这里选择微软文件,(未完成)
                return "Microsoft";
            }

            // 4. 其它默认走 Authelia 验证
            return "Authelia";
        };
    })
    // 2.1 自省模式, 这里使用 Authelia 的非透明 token 方案,调用函数时 bearer token 中的 token 为非 jwt 格式,
    // 接收到 token 后,根据配置到 Authority 中的服务器获取 jwt 用户信息 
    // 要引用 `Duende.AspNetCore.Authentication.OAuth2Introspection` nuget 包
    // 需要在 authelia 中配置一个 odic client 节点,并指定 client_id 与 client_secret
    .AddOAuth2Introspection("Authelia_Introspection", options =>
    {
        options.Authority = "https://auth.xapi.fun"; // 验证服务器
        options.ClientId = "iox-webserver-app";
        options.ClientSecret = "xxxx";
        options.ClientCredentialStyle = ClientCredentialStyle.AuthorizationHeader; # 指定验证方式使用 Basic, authelia 支持该模式
        options.NameClaimType = "preferred_username"; # user 中的 name 使用 preferred_username
        options.RoleClaimType = "groups"; # user 中的 role 使用 groups
        // options.AuthenticationType = "oidc"; // 重写默认的验证方案名称
        options.CacheDuration = TimeSpan.FromSeconds(60); // 程序默认带缓存功能, 这里重写缓存时长, 默认 5 分钟

        // 不幸的时,authelia 验证成功后,返回的 token 中没有 preferred_username 与 groups
        // 这里添加自定义处理逻辑, 从 userinfo 接口中获取 preferred_username 与 groups
        options.Events = new OAuth2IntrospectionEvents
        {
            // 在验证成功后,添加自定义处理逻辑
            OnTokenValidated = async context =>
            {
                var accessToken = context.SecurityToken;
                var cacheKey = $"UserInfo_{accessToken.GetHashCode()}";
                var cache = context.HttpContext.RequestServices.GetRequiredService<HybridCache>();

                // 这里使用了并发缓存
                var userInfo = await cache.GetOrCreateAsync(
                    cacheKey,
                    (Token: accessToken, options.Authority),
                    static async (state, token) =>
                    {
                        // 注意:这里使用了 static,确保不会捕获任何外部变量
                        using var client = new HttpClient();
                        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", state.Token);

                        var url = $"{state.Authority?.TrimEnd('/')}/api/oidc/userinfo";
                        return await client.GetFromJsonAsync<JsonElement>(url, token);
                    }
                );

                if (context.Principal?.Identity is ClaimsIdentity identity)
                {
                    identity.AddClaim(new Claim("preferred_username", userInfo.GetProperty("preferred_username").GetString()!));

                    if (userInfo.TryGetProperty("groups", out var groups))
                    {
                        foreach (var g in groups.EnumerateArray())
                        {
                            identity?.AddClaim(new Claim("groups", g.GetString()!));
                        }
                    }
                }

                await Task.CompletedTask;
            },

            // 当出错的时候,调用此函数在返回头中加入错误信息, 自己调试时使用  
            OnAuthenticationFailed = context =>
            {
                // 这里可以查看或记录获取的性的错误 错误信息
                context.Response.Headers.Append("x-auth-failed-reason", context.Error);
                return Task.CompletedTask;
            },
        };
    })
    // 4. 这里使用 jwt token 方案,调用函数时 bearer token 中的 token 为 jwt 格式, 可以为 jwt access_token 或是 identity_token    
    .AddJwtBearer("Authelia", options =>
    {
        options.Authority = "https://auth.xapi.fun"; // 验证服务器地址
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidIssuer = "https://auth.xapi.fun", // token 中的签发地址
            ValidateIssuer = true,

            ValidAudience = "iox-linkedin-app", // token 中的 aud (client_id)
            ValidateAudience = false, // authelia 返回的 jwt accesstoken 默认 aud 是空的,这是配置为不校验, 如果使用 identity_token 里面有 aud, 可配置为 true

            ValidateLifetime = true, // 验证 token 的有效期

            NameClaimType = "preferred_username",  # user 中的 name 使用 preferred_username
            RoleClaimType = "groups", // # user 中的 role 使用 groups
        };

        // 当出错的时候,调用此函数在返回头中加入错误信息, 自己调试时使用
        options.Events = new JwtBearerEvents
        {
            OnAuthenticationFailed = context =>
            {
                // 返回值中带入 错误信息
                context.Response.Headers.Append("x-auth-failed-reason", context.Exception.Message);
                return Task.CompletedTask;
            },
        };
    });

authelia 配置

自省模式

为 api 服务器配置一个 oidc client, 然后填写到 api 服务器上

- client_id: 'iox-webserver-app'
  client_name: 'iox web server app'
  claims_policy: 'default'
  # docker run --rm authelia/authelia:latest authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72 --random.charset rfc3986        
  client_secret: 'xxx' # 生成 token 的 hash 值, 客户端填写原值
  public: false # 必须设置为 false
  authorization_policy: 'one_factor' # 验证模式为一次验证
  scopes: ['openid', 'profile', 'appinfo_scope' ] # 客户端的访问权限
  options.Authority = "https://auth.xapi.fun"; // 验证服务器
  options.ClientId = "iox-webserver-app";
  options.ClientSecret = "xxxx";
  options.ClientCredentialStyle = ClientCredentialStyle.AuthorizationHeader; # 指定验证方式使用 Basic, authelia 支持该模式
  options.NameClaimType = "preferred_username"; #  user 中的 name 使用 preferred_username
  options.RoleClaimType = "groups"; # authelia 不带 role , 这里将 user 中的 role 使用 groups替代

jwt access_token 模式

authelia 默认返回非透明 (非 jwt 格式) access_token, 如果要返回 jwt access_token, 需要配置如下

   clients:
      - client_id: 'iox-linkedin-app'        
        response_types: 'code'
        access_token_signed_response_alg: 'RS256' # 填写 access_token 的签名算法, 这样就强制 authelia 返回 jwt 格式的 access_token
上一篇
下一篇