如何将多个身份提供者与 Identity Server 4 集成,同时还使用 Cookie 和 JWT 身份验证

问题描述 投票:0回答:0

目标

我正在尝试在图中 here 中实现@ToreNestenius 描述的场景。

配置

我有多个 API 服务、一个 Identity Server 4 实例和一个 React SPA 前端。

我在我的 IS4 上为 React SPA 配置了一个客户端,允许以下范围 { openid, profile, email, offline_access, myApi1, myApi2 }

我为 React 应用程序配置了 OIDC,效果很好。

我的 API 配置了多个身份验证方案,Cookie 和 JWT,并为开发人员配置了 Swagger(使用与 React 相同的客户端),它使用 OpenApiOAuthFlow 并且运行良好。

我的 API 和我的 ID 服务器都使用以下身份验证设置:

const string AUTH_SCHEME = "JWT_OR_COOKIE";
const string BEARER = JwtBearerDefaults.AuthenticationScheme;
const string COOKIE = "Cookies";
const string OIDC = "oidc";
const string COOKIE_NAME = "myapp.identity";

var localAuthority = "https://localhost:10000";

services.Configure<SecurityStampValidatorOptions>(
    options => 
    {
        options.ValidationInterval = TimeSpan.FromDays(90);
    });

services
    // JWT + Cookie auth adapted from Rick Strahl's approach @ https://weblog.west-wind.com/posts/2022/Mar/29/Combining-Bearer-Token-and-Cookie-Auth-in-ASPNET
    .AddAuthentication(
        options =>
        {
            options.DefaultScheme = AUTH_SCHEME;
            options.DefaultChallengeScheme = AUTH_SCHEME;
        })
    .AddJwtBearer(BEARER,
        options =>
        {
            options.Authority = localAuthority;
            options.ForwardChallenge = BEARER;

            // Taken from: https://docs.microsoft.com/en-us/answers/questions/575339/jwt-with-authorization-client-side.html
            options.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuer = true,
                ValidIssuer = localAuthority,

                ValidateAudience = true,
                ValidAudience = "MyResourceName",

                ValidateLifetime = true,
                ValidateIssuerSigningKey = true,
            };
        })
    .AddCookie(COOKIE,
        options =>
        {
            options.Cookie.Name = COOKIE_NAME;
            options.ForwardChallenge = OIDC;
            options.AccessDeniedPath = "/Account/AccessDenied";
        })
    .AddOpenIdConnect(OIDC,
        options =>
        {
            options.Authority = authority;
            options.ClientId = client.ClientId;
            options.ClientSecret = client.ClientSecret;
            options.ResponseType = "code";
            options.CallbackPath = "/signin-oidc";

            options.Scope.Clear();
            foreach (var scope in client.AllowedScopes) // { openid, profile, email, offline_access, myApi1, myApi2 }
                options.Scope.Add(scope);
            options.GetClaimsFromUserInfoEndpoint = true;
            options.SaveTokens = true;
        })
    .AddPolicyScheme(AUTH_SCHEME, AUTH_SCHEME,
        options => options.ForwardDefaultSelector =
            context =>
            {
                string auth = context.Request.Headers[HeaderNames.Authorization];
                if (!string.IsNullOrEmpty(auth) && auth.StartsWith(BEARER))
                    return BEARER;
                return COOKIE;
            });

所有这些都很好用。

问题从哪里开始

现在我正在尝试为外部 ID 提供商添加第二个 OpenIdConnect 方案。

我已经在 Azure 中注册了一个应用程序,重定向 URL 为

https://localhost:10000/signin-microsoft
,这是我在本地托管的 URL。

我已经尝试从 damienbod 的方法中进行调整 - 通过将以下 OIDC 配置添加到我的 IS4 服务集合中:

var azureAuthority = "https://login.microsoftonline.com/common/v2.0/";

services
// https://identityserver4.readthedocs.io/en/latest/topics/signin_external_providers.html#state-url-length-and-isecuredataformat
.AddOidcStateDataFormatterCache()
.AddOpenIdConnect("AzureAD", "AzureAD",
    options =>
    {
        options.Authority = azureAuthority;
        options.ClientId = "MY_AZURE_REGISTERED_APPLICATION_CLIENT_GUID";
        options.ClientSecret = "MY_AZURE_REGISTERED_APPLICATION_SECRET_VALUE";
        options.ResponseType = "code";
        options.CallbackPath = "/signin-microsoft";

        options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
        options.SignOutScheme = IdentityServerConstants.SignoutScheme;
        options.RemoteAuthenticationTimeout = TimeSpan.FromSeconds(300);

        options.Scope.Add("openid");
        options.Scope.Add("profile");
        options.Scope.Add("email");
        options.Scope.Add("offline_access");

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = false, // multi tenant => means all tenants can use this
            NameClaimType = "name",
        };
        options.Prompt = "login"; // login, consent select_account
    })

当我登录并使用外部 Azure 提供商时,它成功地重定向了我,我能够登录并重定向回我的回调。

我正在使用从上面链接的 damienbod 代码改编的挑战和回调,看起来像:

挑战模型:

[AllowAnonymous]
public class ChallengeModel : PageModel
{
    readonly IIdentityServerInteractionService _interactionService;
    public ChallengeModel(IIdentityServerInteractionService interactionService) =>
        _interactionService = interactionService;

    public IActionResult OnGet(string scheme, string returnUrl)
    {
        if (string.IsNullOrEmpty(returnUrl))
            returnUrl = "~/";

        // validate returnUrl - either it is a valid OIDC URL or back to a local page
        if (!Url.IsLocalUrl(returnUrl) &&
            !_interactionService.IsValidReturnUrl(returnUrl))
            throw new Exception("invalid return URL"); // user might have clicked on a malicious link - should be logged

        // start challenge and roundtrip the return URL and scheme 
        var props = new AuthenticationProperties
        {
            RedirectUri = Url.Page("/externallogin/callback"),

            Items =
            {
                { "returnUrl", returnUrl },
                { "scheme", scheme },
            }
        };

        return Challenge(props, scheme);
    }
}

回调模型:

public class CallbackModel : PageModel
{
    private readonly UserManager<MyIdentityUser> _userManager;
    private readonly SignInManager<MyIdentityUser> _signInManager;
    private readonly IIdentityServerInteractionService _interaction;
    private readonly ILogger<CallbackModel> _logger;
    private readonly IEventService _events;

    public CallbackModel(
        IIdentityServerInteractionService interaction,
        IEventService events,
        ILogger<CallbackModel> logger,
        UserManager<MyIdentityUser> userManager,
        SignInManager<MyIdentityUser> signInManager)
    {
        _userManager = userManager;
        _signInManager = signInManager;
        _interaction = interaction;
        _logger = logger;
        _events = events;
    }

    public async Task<IActionResult> OnGet()
    {
        // read external identity from the temporary cookie
        var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
        if (result?.Succeeded != true)
            throw new Exception("External authentication error");

        var externalUser = result.Principal;

        if (_logger.IsEnabled(LogLevel.Debug))
        {
            var externalClaims = externalUser.Claims.Select(c => $"{c.Type}: {c.Value}");
            _logger.LogDebug("External claims: {@claims}", externalClaims);
        }

        // lookup our user and external provider info
        // try to determine the unique id of the external user (issued by the provider)
        // the most common claim type for that are the sub claim and the NameIdentifier
        // depending on the external provider, some other claim type might be used
        var userIdClaim = externalUser.FindFirst(JwtClaimTypes.Subject) ??
                          externalUser.FindFirst(ClaimTypes.NameIdentifier) ??
                          throw new Exception("Unknown userid");

        var provider = result.Properties.Items["scheme"];
        var providerUserId = userIdClaim.Value;

        // find external user
        var user = await _userManager.FindByLoginAsync(provider, providerUserId);
        if (user == null)
        {
            // this might be where you might initiate a custom workflow for user registration
            // in this sample we don't show how that would be done, as our sample implementation
            // simply auto-provisions new external user
            user = await AutoProvisionUserAsync(provider, providerUserId, externalUser.Claims);
        }

        // this allows us to collect any additional claims or properties
        // for the specific protocols used and store them in the local auth cookie.
        // this is typically used to store data needed for signout from those protocols.
        var additionalLocalClaims = new List<Claim>();
        var localSignInProps = new AuthenticationProperties();
        CaptureExternalLoginContext(result, additionalLocalClaims, localSignInProps);

        // issue authentication cookie for user
        await _signInManager.SignInWithClaimsAsync(user, localSignInProps, additionalLocalClaims);

        // delete temporary cookie used during external authentication
        await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);

        // retrieve return URL
        var returnUrl = result.Properties.Items["returnUrl"] ?? "~/";

        // check if external login is in the context of an OIDC request
        var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
        await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.Id, user.UserName, true, context?.Client.ClientId));

        if (context != null && context.IsNativeClient())
            // The client is native, so this change in how to
            // return the response is for better UX for the end user.
            return this.LoadingPage(returnUrl);

        return Redirect(returnUrl);
    }

    private async Task<MyIdentityUser> AutoProvisionUserAsync(string provider, string providerUserId, IEnumerable<Claim> claims)
    {
        var sub = Guid.NewGuid().ToString();

        var user = new MyIdentityUser
        {
            Id = sub,
            UserName = sub, // don't need a username, since the user will be using an external provider to login
        };

        // email
        var email = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Email)?.Value ??
                    claims.FirstOrDefault(x => x.Type == ClaimTypes.Email)?.Value;
        if (email != null)
            user.Email = email;

        // create a list of claims that we want to transfer into our store
        var filtered = new List<Claim>();

        // user's display name
        var name = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Name)?.Value ??
                   claims.FirstOrDefault(x => x.Type == ClaimTypes.Name)?.Value;
        if (name != null)
            filtered.Add(new Claim(JwtClaimTypes.Name, name));
        else
        {
            var first = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.GivenName)?.Value ??
                        claims.FirstOrDefault(x => x.Type == ClaimTypes.GivenName)?.Value;
            var last = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.FamilyName)?.Value ??
                       claims.FirstOrDefault(x => x.Type == ClaimTypes.Surname)?.Value;
            if (first != null && last != null)
                filtered.Add(new Claim(JwtClaimTypes.Name, first + " " + last));
            else if (first != null)
                filtered.Add(new Claim(JwtClaimTypes.Name, first));
            else if (last != null)
                filtered.Add(new Claim(JwtClaimTypes.Name, last));
        }

        var identityResult = await _userManager.CreateAsync(user);
        if (!identityResult.Succeeded)
            throw new Exception(identityResult.Errors.First().Description);

        if (filtered.Any())
        {
            identityResult = await _userManager.AddClaimsAsync(user, filtered);
            if (!identityResult.Succeeded)
                throw new Exception(identityResult.Errors.First().Description);
        }

        identityResult = await _userManager.AddLoginAsync(user, new UserLoginInfo(provider, providerUserId, provider));
        if (!identityResult.Succeeded)
            throw new Exception(identityResult.Errors.First().Description);

        return user;
    }

    // if the external login is OIDC-based, there are certain things we need to preserve to make logout work
    // this will be different for WS-Fed, SAML2p or other protocols
    private void CaptureExternalLoginContext(AuthenticateResult externalResult, List<Claim> localClaims, AuthenticationProperties localSignInProps)
    {
        // capture the idp used to login, so the session knows where the user came from
        localClaims.Add(new Claim(JwtClaimTypes.IdentityProvider, externalResult.Properties.Items["scheme"]));

        // if the external system sent a session id claim, copy it over
        // so we can use it for single sign-out
        var sid = externalResult.Principal.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId);
        if (sid != null)
            localClaims.Add(new Claim(JwtClaimTypes.SessionId, sid.Value));

        // if the external provider issued an id_token, we'll keep it for signout
        var idToken = externalResult.Properties.GetTokenValue("id_token");
        if (idToken != null)
            localSignInProps.StoreTokens(new[]
            {
                new AuthenticationToken { Name = "id_token", Value = idToken }
            });
    }
}

但是,当我的回调完成时,出现以下异常:

System.Exception: OpenIdConnectAuthenticationHandler: message.State is null or empty.

Exception: An error was encountered while handling the remote login.
Microsoft.AspNetCore.Authentication.RemoteAuthenticationHandler<TOptions>.HandleRequestAsync()
IdentityServer4.Hosting.FederatedSignOut.AuthenticationRequestHandlerWrapper.HandleRequestAsync()
Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware.InvokeInternal(HttpContext context)
Microsoft.Azure.AppConfiguration.AspNetCore.AzureAppConfigurationRefreshMiddleware.InvokeAsync(HttpContext context)
Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware.Invoke(HttpContext context)

我的Identity server配置比较标准,如下:

.AddIdentity<MyIdentityUser, IdentityRole>(
    options =>
    {
        options.SignIn.RequireConfirmedAccount = true;
        options.SignIn.RequireConfirmedPhoneNumber = false;

        options.Lockout.MaxFailedAccessAttempts = 5;

        options.Password.RequiredLength = 8;
        options.Password.RequireLowercase = true;
        options.Password.RequireUppercase = true;
        options.Password.RequiredUniqueChars = 0;
        options.Password.RequireNonAlphanumeric = false;
        options.Password.RequireDigit = false;
    })
.AddTokenProvider<DataProtectorTokenProvider<MyIdentityUser>>(TokenOptions.DefaultProvider)
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<MyDbContext>()
.AddSignInManager<SignInManager<MyIdentityUser>>()
.AddClaimsPrincipalFactory<MyClaimsPrincipalFactory>();

services
    .Configure<DataProtectionTokenProviderOptions>(
        options =>
        {
            options.TokenLifespan = TimeSpan.FromHours(24);
        })
    .AddLocalApiAuthentication()
    .AddAuthorization(...);

services
.AddIdentityServer(
    options =>
    {
        options.Events.RaiseErrorEvents = true;
        options.Events.RaiseInformationEvents = true;
        options.Events.RaiseFailureEvents = true;
        options.Events.RaiseSuccessEvents = true;
        options.EmitStaticAudienceClaim = false;
    })
.AddConfigurationStore(
    options =>
    {
        options.ConfigureDbContext = b => b.UseSqlServer(dbConnectionStr, sql =>
            sql.MigrationsAssembly(migrationsAssembly));
    })
.AddOperationalStore(
    options =>
    {
        options.ConfigureDbContext = b => b.UseSqlServer(dbConnectionStr, sql =>
            sql.MigrationsAssembly(migrationsAssembly));
    })
.AddProfileService<ProfileService>()
.AddSigningCert(builder.Configuration[CommonStatics.SECRET_IDENTITY_CERT]);

services
.ConfigureApplicationCookie(config => config.Cookie.Name = CommonStatics.ID_COOKIE);

我的申请流程如下

if (!app.Environment.IsProduction())
    app.UseDeveloperExceptionPage();

// NOTE regarding Middleware ordering: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-6.0
app.UseHttpLogging();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseIdentityServer();
app.UseJwtTokenMiddleware();
app.UseAuthorization();

app.MapRazorPages();
app.UseSwagger();
app.UseSwaggerUI(...);
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());

问题

  • AzureAD OIDC 方案仅从 AzureAD 请求 4 个范围,这是有道理的,因为这是 Azure 注册应用程序所知道的所有内容......但是我如何让我的本地身份服务器验证其他客户端范围请求,例如“myApi1” ,“myApi2”,AzureAD 注册的应用程序对此一无所知?
    • 我有机会在回调中注入此类范围声明,但我如何验证调用客户端是否可以访问这些范围?似乎是错误的方法。
  • 为什么我会收到
    OpenIdConnectAuthenticationHandler: message.State is null or empty.
    消息?
  • 我的所有 API 服务都应该使用我的 LocalIS4 和“AzureAD”方案调用 .AddOpenIdConnect(...) 吗?或者应该只有 IS4 服务器配置 AzureAD 方案?
  • 对于使用外部提供者的用户——我应该以某种方式在我的用户数据库中记录他们的提供者吗?
    • 例如如果它们是外部提供的,那么它们的密码不应设置,也不可设置。
    • 我的 IS4 用户界面在用户登录时显示密码重置选项 - ResetPassword 应该如何与外部身份提供者一起使用?如果我跟踪用户是否在外部配置,我是否应该为他们禁用 ResetPassword 和 ForgotPassword 功能?这通常是如何处理的?
  • 我必须按照@damienbod 的建议,在 AzureAD 方案的 TokenValidationParameters 中将 ValidateIssuer 设置为 false 吗?

编辑:

我玩了一些并引入了另一个错误,“Correlation Failed”,所以我不得不将以下选项添加到 CorrelationCookie:

options.CorrelationCookie = new Microsoft.AspNetCore.Http.CookieBuilder
{
    HttpOnly = true,
    SameSite = Microsoft.AspNetCore.Http.SameSiteMode.None,
    SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always,
    IsEssential = false,
    Expiration = TimeSpan.FromMinutes(10)
};

这解决了“关联失败”错误。

我还能够通过将以下事件处理程序添加到 OIDC 连接选项来修复

OpenIdConnectAuthenticationHandler: message.State is null or empty.
错误,它似乎已经修复了所有问题:

options.Events = new OpenIdConnectEvents()
{
    OnRedirectToIdentityProvider = context =>
    {
        return Task.CompletedTask;
    },
    OnRemoteFailure = context =>
    {
        if (context.Failure.Message == "OpenIdConnectAuthenticationHandler: message.State is null or empty.")
        {
            context.Response.Redirect("/");
            context.HandleResponse();
        }
        return Task.CompletedTask;
    },
    OnAuthorizationCodeReceived = context =>
    {
        return Task.CompletedTask;
    },
    OnMessageReceived = context =>
    {
        return Task.CompletedTask;
    }
};

注意:OnAuthorizationCodeReceived 事件正在触发,身份验证成功,回调也按预期工作。回调后,将触发 OnMessageReceived 事件,但消息为空,因此 OIDC 处理程序无法很好地处理它。因此,我们只是忽略该特定错误。您也许可以将 context.HandleResponse 移动到 OnMessageReceived 事件中,但我看不到要在其中寻找什么。

c# azure asp.net-core azure-active-directory identityserver4
© www.soinside.com 2019 - 2024. All rights reserved.