目标
我正在尝试在图中 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());
问题
OpenIdConnectAuthenticationHandler: message.State is null or empty.
消息?编辑:
我玩了一些并引入了另一个错误,“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 事件中,但我看不到要在其中寻找什么。