如何实现基于密码流的IdentityServer4单点登录?

How to implement IdentityServer4 SSO based on password flow?

我有两个网络应用程序:

  1. 域 1 中的 webApp1 (Spa1 -> WebApi1 -> IdentityServer4 -> db1)

  2. 域 2 中的 webApp2 (Spa2 -> WebApi2 -> db2)

用户故事:

  1. 最终用户 John 已经通过位于 WebApi1 下的 IdentityServer4 中的密码流授权,因此 Spa1 具有 "WebApi1" 范围的 JWT 并刷新 令牌也是如此。
  2. John 在 Spa1 单击按钮 "Go to Spa2" 然后他被重定向到 Spa2。
  3. 在约翰的浏览器中,打开了一个带有Spa2的新页面,约翰看到他已经通过Spa2中的IdentityServer4进行了身份验证 并授权 WebApi2(John 可以使用 Spa2 功能而无需额外的登录对话框),因为 db2 有一个映射 db1.users->db2.users(因此 webApp2 可以使用它自己的角色)。

这类似于用户在 gmail spa 中阅读邮件并从一封信跟随 link 到 youtube 的情况(无需额外的身份验证操作) 并看到他已经通过 google.

的身份验证

我习惯在配置中使用授权代码流

new Client
{
    ClientId = "app1,
    AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
    AccessTokenType = AccessTokenType.Jwt,
    ClientSecrets =
    {
        new Secret("secret1".Sha256())
    },
    AllowedScopes = { "api1"},
    AllowOfflineAccess = true,
    AlwaysSendClientClaims = true,
    AlwaysIncludeUserClaimsInIdToken = true
},

new Client {
    ClientId = "app2",
    ClientSecrets = 
    {
        new Secret("secret2".Sha256())
    },
    Enabled = true,
    AllowedGrantTypes = GrantTypes.Code, 
    RequireConsent = false,
    AllowRememberConsent = false,
    RedirectUris =
        new List<string> {
            "http://localhost:5436/account/oAuth2"
        },

    AllowedScopes = { "api2" },
    AccessTokenType = AccessTokenType.Jwt
}

,但需要通过浏览器进行额外的身份验证 这是不必要的过程,因为用户已经通过身份验证。

我应该如何在 IdentityServer4 中实现这个身份验证方案?

这对您不起作用,因为您的登录流程是通过 ResourceOwnerCredentials 授权类型实现的,这意味着当用户 John 访问 spa1 时,spa1 通过自定义登录流程记录用户 John。

为了开箱即用,最简单的方法和最推荐的方法可能是将 spa1 转换为使用一种首选的授权类型(例如 ImplicitAuthorizationCode示例),然后一旦用户 John 通过您的 IdentityServer 4 服务的中央登录页面登录,它将留下 cookie,然后任何后续令牌请求尝试将直接登录用户并将请求的令牌发布到适当的客户端应用程序(也可以跳过正如您在示例中所做的那样,可以选择同意。

还有一种我能想到的方法,我不推荐给你,但由于特定客户要求保留ResourceOwnerCredentials授权类型和自定义登录页面,我亲自实现了一次,但仍然实现单点登录行为。如果您的 spa1 和 IdentityServer4 都托管在同一域(因此 spa1.yourdomain.comauth.yourdomain.com)并且您的用户存储(用户名和凭据)在您的 IdentityServer4 和 spa1 之间共享,您可以在技术上对用户在 spa1 登录页面中输入凭据,以编程方式将 POST 请求提交到身份服务器 4 登录页面,并使用包含用户凭据的表单详细信息,从响应中获取 cookie,然后将 cookie 存储在用户的客户端中。每当您的用户 John 尝试访问 spa2 时,到 IdentityServer4 的重定向仍然会发生,但是整个登录流程将被规避,因为已经有一个 cookie 可以自动让用户登录。如果您决定按照这些方式实施某些操作,请确保研究安全问题(会有很多)并真正评估是否需要。

我的解决方案:

  1. 创建自定义代码授权机构,为 app1 颁发授权码并将其重定向到 app2。
  2. 创建通过代码对用户进行身份验证的扩展授权实现。

让我们先看看它是如何工作的:

  1. 授权用户(我使用邮递员)。

要求:

POST /connect/token
Content-Type: application/x-www-form-urlencoded
Host: localhost:5000
grant_type=password&client_id=app1&client_secret=app1secret&scope=offline_access%20app1.api%20auth.api&username=tu1&password=111111

回复:

{"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE0MDExYjViNGM0ZGYxYTUzZWFhMzhiMjBiZWVlOGM5IiwidHlwIjoiSldUIn0.eyJuYmYiOjE1NTcwODE1OTYsImV4cCI6MTU1NzA4NTE5NiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIiwiYXVkIjpbImh0dHA6Ly9sb2NhbGhvc3Q6NTAwMC9yZXNvdXJjZXMiLCJhcHAxLmFwaSIsImF1dGguYXBpIl0sImNsaWVudF9pZCI6ImFwcDEiLCJzdWIiOiJ0dTEiLCJhdXRoX3RpbWUiOjE1NTcwODE1OTYsImlkcCI6ImxvY2FsIiwic2NvcGUiOlsiYXBwMS5hcGkiLCJhdXRoLmFwaSIsIm9mZmxpbmVfYWNjZXNzIl0sImFtciI6WyJwd2QiXX0.bV1lvPs8AFUq7kcCAEMz4rS2vOmUIzrogN3EByQViBkKNFF6ijrizVc2GxiXRNTwl35Kgsb7beoFaVy4Ai2RmyMxmyJumwiwR0-wbX_mrs-XcfADfhEdLQJWLvAkbm2jm3FvDC-7F6S5Mip-QtbcXdgqg5oQo53nBJDXc7bsn1MaKPkivR1tg9CjA0uOQC891aBr4BzRZeH43YpVjxO7zzYL9vcplIL79nkhiG4iVfo7Ti8JJa4Q7HzH6lj0V_NrTY3BRzvCHVPNy0cFtfFTE1l_abMel1ftozyvFtrsTgVqRZhFfzY0d_7K8M9wtXAa7vbYW7oAhvnxVlga4HX_zg",
    "expires_in": 3600,
    "token_type": "Bearer",
    "refresh_token": "26df6326251b7590cf6eb9898967e814ff291712aa7504ac84f9d8ae07374d3c"}

好!我们得到了带有有效负载的令牌:

{
  "nbf": 1557072351,
  "exp": 1557075951,
  "iss": "http://localhost:5000",
  "aud": [
    "http://localhost:5000/resources",
    "app1.api",
    "auth.api"
  ],
  "client_id": "app1",
  "sub": "tu1",
  "auth_time": 1557072351,
  "idp": "local",
  "scope": [
    "app1.api",
    "auth.api", //!!! 
    "offline_access"
  ],
  "amr": [
    "pwd"
  ]
}

令牌的范围是“auth.api”——这意味着我们可以请求代码。

  1. 使用代码和状态请求重定向到 WebApp2。

要求:

GET /api/CodeAuthority?state=random_base64_value_generated_in_spa1_at_the_begining
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjE0MDExYjViNGM0ZGYxYTUzZWFhMzhiMjBiZWVlOGM5IiwidHlwIjoiSldUIn0.eyJuYmYiOjE1NTcwODE1OTYsImV4cCI6MTU1NzA4NTE5NiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIiwiYXVkIjpbImh0dHA6Ly9sb2NhbGhvc3Q6NTAwMC9yZXNvdXJjZXMiLCJhcHAxLmFwaSIsImF1dGguYXBpIl0sImNsaWVudF9pZCI6ImFwcDEiLCJzdWIiOiJ0dTEiLCJhdXRoX3RpbWUiOjE1NTcwODE1OTYsImlkcCI6ImxvY2FsIiwic2NvcGUiOlsiYXBwMS5hcGkiLCJhdXRoLmFwaSIsIm9mZmxpbmVfYWNjZXNzIl0sImFtciI6WyJwd2QiXX0.bV1lvPs8AFUq7kcCAEMz4rS2vOmUIzrogN3EByQViBkKNFF6ijrizVc2GxiXRNTwl35Kgsb7beoFaVy4Ai2RmyMxmyJumwiwR0-wbX_mrs-XcfADfhEdLQJWLvAkbm2jm3FvDC-7F6S5Mip-QtbcXdgqg5oQo53nBJDXc7bsn1MaKPkivR1tg9CjA0uOQC891aBr4BzRZeH43YpVjxO7zzYL9vcplIL79nkhiG4iVfo7Ti8JJa4Q7HzH6lj0V_NrTY3BRzvCHVPNy0cFtfFTE1l_abMel1ftozyvFtrsTgVqRZhFfzY0d_7K8M9wtXAa7vbYW7oAhvnxVlga4HX_zg
User-Agent: PostmanRuntime/7.11.0
Accept: */*
Cache-Control: no-cache
Postman-Token: 8bec320a-0cc9-4aeb-aba1-acdbd89384cf
Host: localhost:5000
accept-encoding: gzip, deflate
Connection: keep-alive

回复:

HTTP/1.1 302
status: 302
Date: Sun, 05 May 2019 19:16:44 GMT
Server: Kestrel
Content-Length: 0
Location: http://WebApp2.test.url?code=random_base64_value_generated_in_is4_api&state=random_base64_value_generated_in_spa1_at_the_begining

请注意,我们将第一步中的令牌放入此获取请求,我们已被重定向到“http://WebApp2.test.url?code=random_base64_value_generated_in_is4_api&state=random_base64_value_generated_in_spa1_at_the_begining”。

  1. 因此,现在可以通过代码和状态从 WebApp2 对用户进行身份验证。

要求:

POST /connect/token
Content-Type: application/x-www-form-urlencoded
User-Agent: PostmanRuntime/7.11.0
Accept: */*
Cache-Control: no-cache
Postman-Token: 4adc90e8-ae6a-421b-8514-8b96e0f7108a
Host: localhost:5000
accept-encoding: gzip, deflate
content-length: 197
Connection: keep-alive
grant_type=app2_auth_code&code=random_base64_value_generated_in_is4_api&client_id=app2&client_secret=app2secret&scope=code.authentication&state=random_base64_value_generated_in_spa1_at_the_begining

回复:

HTTP/1.1 200
status: 200
Date: Sun, 05 May 2019 19:25:41 GMT
Content-Type: application/json; charset=UTF-8
Server: Kestrel
Cache-Control: no-store, no-cache, max-age=0
Pragma: no-cache
Transfer-Encoding: chunked
{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IjE0MDExYjViNGM0ZGYxYTUzZWFhMzhiMjBiZWVlOGM5IiwidHlwIjoiSldUIn0.eyJuYmYiOjE1NTcwODQzNDEsImV4cCI6MTU1NzA4Nzk0MSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIiwiYXVkIjpbImh0dHA6Ly9sb2NhbGhvc3Q6NTAwMC9yZXNvdXJjZXMiLCJjb2RlLmF1dGhlbnRpY2F0aW9uIl0sImNsaWVudF9pZCI6ImFwcDIiLCJzdWIiOiJ0dTEiLCJhdXRoX3RpbWUiOjE1NTcwODQzNDEsImlkcCI6ImxvY2FsIiwic2NvcGUiOlsiY29kZS5hdXRoZW50aWNhdGlvbiJdLCJhbXIiOlsiYXBwMl9hdXRoX2NvZGUiXX0.bDonw4SjGGqgwxnJeJoBP4-DfjWcAXUsXrvBx5Qav3cS329g9qciXzBcEpFmNB41De3GW-ocVFb8AFgGGCTENW3B2lL9HdopJ9C2ksPRwB1qTJ9S98HZZjOT0wQ2N-AbfQWAJlH12qGeml2UjB-L-afFAPVM-KpOh4my9znvUJWV_L_7q2Lwpv23fSkyGDahQCcZVLcurCjx8uQp1xliOF7b6qZ87kwh5brxGvUXP3oWjfmBvG_PsAFvGHZwgicjTWK7ED_OGTULCvtCtNO5RwW9_HINIl-217KnYgsrHNfaFCiv03vKXckvmkzfacreO0FaDr3r0nS2dMGrkyZ2sA","expires_in":3600,"token_type":"Bearer"}

我们刚刚交换了令牌的代码和状态:

{
  "nbf": 1557073472,
  "exp": 1557077072,
  "iss": "http://localhost:5000",
  "aud": [
    "http://localhost:5000/resources",
    "code.authentication"
  ],
  "client_id": "app2",
  "sub": "tu1",     //!!!
  "auth_time": 1557073472,
  "idp": "local",
  "scope": [
    "code.authentication" //!!!
  ],
  "amr": [
    "app2_auth_code"
  ]
}

现在 WebApp2 知道谁(子)发起了重定向。

代码(Solution on github):

IdentityServer4:

namespace TestIdentityServer4
{
    public class Startup
    { 
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddIdentityServer()
                .AddDeveloperSigningCredential()
                .AddInMemoryApiResources(new List<ApiResource>()
                {
                    //Api which returns redirect url with code and state.
                    new ApiResource("auth.api", "Auth API"),
                    //App1 api. Just to show that app1 has some functionality (IdentityController).
                    new ApiResource("app1.api", "App1 API"),
                    //This resource is authentification functionality implemented by AuthCodeValidator.
                    new ApiResource("code.authentication", "Authentication by code")
                })
                .AddInMemoryClients(new List<Client>()
                {
                    //web app1
                    new Client
                    {
                        ClientId = "app1",
                        ClientSecrets =
                        {
                            new Secret("app1secret".Sha256())
                        },
                        AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                        AllowedScopes =
                        {
                            "app1.api",
                            "auth.api"
                        },
                        AllowOfflineAccess = true
                    },
                    //web app2
                    new Client
                    {

                        ClientId = "app2",
                        ClientSecrets = new List<Secret>
                        {
                            new Secret("app2secret".Sha256())
                        },

                        AllowedGrantTypes = { "app2_auth_code" },

                        AllowedScopes = new List<string>
                        {
                            "code.authentication"
                        }
                    }
                })
                //App1 users for test purpose
                .AddTestUsers( new List<TestUser>()
                {
                    new TestUser()
                    {
                        Username = "tu1",
                        Password = "111111",
                        SubjectId = "tu1"
                    }
                })
                //Regestring of the custom validator
                .AddExtensionGrantValidator<AuthCodeValidator>();

            //Our IS4 has the custom api (CodeAuthorityController). It is also a resorce that should be protected.
            //It should be awailable fore user authorized in app1.
            services.AddAuthentication(opt =>
            {
                opt.DefaultScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
                opt.DefaultAuthenticateScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;

            })
                .AddIdentityServerAuthentication(
                    opt =>
                    {
                        opt.Authority = "http://localhost:5000";
                        opt.RequireHttpsMetadata = false;
                        opt.ApiName = "auth.api";
                    });

            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseCors();
            app.UseIdentityServer();
            app.UseMvc();
        }
    }
}

代码权限:

namespace TestIdentityServer4.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    [Authorize]
    public class CodeAuthorityController : ControllerBase
    {
        [HttpGet()]
        public IActionResult Get()
        {
            try
            {
                string state = this.Request.Query["state"];
                if (string.IsNullOrEmpty(state))
                    return StatusCode(500);

                var code = GenerateCode();

                SaveCodeAndState(code, state);

                return Redirect($"http://WebApp2.test.url?code={code}&state={state}");
            }
            catch (Exception e)
            {
                //Log e
                return StatusCode(500);
            }
        }

        private string GenerateCode()
        {
            //CryptoRandom.CreateUniqueId(16)
            return "random_base64_value_generated_in_is4_api";
        }

        /// <summary>
        /// Save the code hash and state hash to storage 
        /// </summary>
        private void SaveCodeAndState(string code, string state)
        {
            //Save the code request ({requestId, app1SessionId, hash(code), hash(state), expTime}) to storage with exp time
            //db.SaveCodeRequest(code.Sha256(), state.Sha256())
        }
    }
}

代码验证器:

namespace TestIdentityServer4.Validators
{
    public class AuthCodeValidator : IExtensionGrantValidator
    {
        public string GrantType => "app2_auth_code";

        public async Task ValidateAsync(ExtensionGrantValidationContext context)
        {
            var code = context.Request.Raw.Get("code");
            var state = context.Request.Raw.Get("state");

            var sub = GetSubByCode(code, state);

            if (string.IsNullOrEmpty(sub))
            {
                context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
                return;
            }

            context.Result = new GrantValidationResult(sub, GrantType);
            return;
        }

        //Check the code and the state (and the request are still active) and returns sub
        private string GetSubByCode(string code, string state)
        {
            return "tu1";
        }
    }
}