使用 IdentityServer4 和 Keycloak 18 进行基于角色的身份验证
Role based auth with IdentityServer4 and Keycloak 18
更新到 keycloak 18 后,它期望 id_token_hint 等于 id_token 和 sign-out 上的 post_logout_redirect_uri。
对于 .net 客户端,我们的 keycloak 管理员创建了新的客户端范围 - csharp-roles 将角色转换为 .net 的方便视图,并具有包括角色声明到 id_token 的设置。
我们禁用了此设置,因为 sign-out url 太大(id_token 包含角色声明)并且 sign-out 完美运行。
但是在此更改之后我们遇到登录问题,IdentityServer 在将令牌转换为 Identity 期间不会为其分配声明,我 猜测 它发生是因为 IdentityServer for MVC 项目读取来自 id_token 的所有声明,请查看下面我的设置
services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.LoginPath = "/Login/Index";
options.LogoutPath = "/Login/Logout";
options.Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = async cookieContext =>
{
var validator = cookieContext.HttpContext.RequestServices
.GetRequiredService<IPrincipalValidator>();
if (!await validator.ValidatePrincipal(cookieContext.Principal.Identity as ClaimsIdentity))
{
//this will force Challenge redirect
cookieContext.RejectPrincipal();
//this will delete Identity Cookie, preventing issue with SessionController which relies on User.Identity.IsAuthenticated
await cookieContext.HttpContext.SignOutAsync(CookieAuthenticationDefaults
.AuthenticationScheme);
}
}
};
})
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
options.MetadataAddress = Configuration["oidc:Metadata"];
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.ClientId = Configuration["oidc:ClientId"];
options.ClientSecret = Configuration["oidc:ClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code;
options.CallbackPath = "/oidc-callback";
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("openid");
options.Scope.Add("email");
options.Scope.Add("profile");
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
ValidateAudience = false,
RoleClaimType =
"roles", // using short name here, this is needed because we replace role claims (see code below, OnTicketReceived event)
};
options.SaveTokens = false; //all tokens are stored in session by TokenSourceHelper
options.Events = new OpenIdConnectEvents
{
OnAuthorizationCodeReceived = context =>
{
// pass current session ID to keycloak so it can use if for backchannel requests
if (context.HttpContext.Session != null)
{
var sessionId = context.HttpContext.Session.GetUniqueSessionId();
context.TokenEndpointRequest.Parameters.Add(
TokenSourceHelper.KeycloakSessionStateParameter, sessionId);
//also reset the session ID if it was previously revoked
var logoutSessions = context.HttpContext.RequestServices
.GetRequiredService<ILogoutSessionManager>();
logoutSessions.Remove(null, sessionId);
}
return Task.CompletedTask;
},
//OnTokenValidated =
OnTokenResponseReceived = context =>
{
var tokenSource =
context.HttpContext.RequestServices.GetRequiredService<ITokenSourceHelper>();
tokenSource.SetTokens(context.TokenEndpointResponse.AccessToken,
context.TokenEndpointResponse.RefreshToken, context.TokenEndpointResponse.IdToken);
return Task.CompletedTask;
},
OnTicketReceived = async context =>
{
var tokenSource =
context.HttpContext.RequestServices.GetRequiredService<ITokenSourceHelper>();
var accessToken = await tokenSource.GetToken();
var handler = new JwtSecurityTokenHandler();
var jwtSecurityToken = handler.ReadJwtToken(accessToken);
//reduce the ticket size: shorten claims length
var identity = (ClaimsIdentity)context.Principal?.Identity;
var roles = jwtSecurityToken.Claims.Where(x => x.Type == identity.RoleClaimType);
identity.AddClaims(roles);
},
OnRemoteFailure = context =>
{
//no need to log the exception, it is logged by Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler
context.Response.Redirect("/Home");
context.HandleResponse();
return Task.CompletedTask;
},
OnRedirectToIdentityProviderForSignOut = async context =>
{
if(context.Properties.Items.ContainsKey(WebTime.Business.DTO.Common.Core.Enums.GeneralConstants.TokenHintKey))
{
var idToken =
context.Properties.Items[
GeneralConstants.TokenHintKey];
context.ProtocolMessage.IdTokenHint = idToken;
}
await Task.CompletedTask;
}
};
})
如您所见,我强制从 access_token 读取角色,现在它可以正常工作,但我的 headers 太长了,因为我的 cookie 有 5大块。
也许还有另一种设置可以帮助 access_token 的真实角色声明?
为什么它从 id_token?
读取角色
提前致谢。
您使用 AddOpenIdConnect 的客户端不需要 peek/look 访问令牌。
客户端应该只关心id token,如果你想减小会话cookie的大小,那么你可以在IdentityServer中设置AlwaysIncludeUserClaimsInIdToken=false。然后,因为你已经设置 options.GetClaimsFromUserInfoEndpoint = true;然后 AddOpenIdConnect 处理程序将从 UserInfo 端点获取剩余的用户详细信息。
Keycloak 发送具有“角色”声明类型名称的角色,默认情况下 asp.net 核心身份不期望此名称,我使用 options.GetClaimsFromUserInfoEndpoint = true;
添加唯一映射:options.ClaimActions.MapUniqueJsonKey(ClaimsIdentity.DefaultRoleClaimType, "roles");
然后已解析的角色数组,显示为字符串数组
更新到 keycloak 18 后,它期望 id_token_hint 等于 id_token 和 sign-out 上的 post_logout_redirect_uri。 对于 .net 客户端,我们的 keycloak 管理员创建了新的客户端范围 - csharp-roles 将角色转换为 .net 的方便视图,并具有包括角色声明到 id_token 的设置。 我们禁用了此设置,因为 sign-out url 太大(id_token 包含角色声明)并且 sign-out 完美运行。 但是在此更改之后我们遇到登录问题,IdentityServer 在将令牌转换为 Identity 期间不会为其分配声明,我 猜测 它发生是因为 IdentityServer for MVC 项目读取来自 id_token 的所有声明,请查看下面我的设置
services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.LoginPath = "/Login/Index";
options.LogoutPath = "/Login/Logout";
options.Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = async cookieContext =>
{
var validator = cookieContext.HttpContext.RequestServices
.GetRequiredService<IPrincipalValidator>();
if (!await validator.ValidatePrincipal(cookieContext.Principal.Identity as ClaimsIdentity))
{
//this will force Challenge redirect
cookieContext.RejectPrincipal();
//this will delete Identity Cookie, preventing issue with SessionController which relies on User.Identity.IsAuthenticated
await cookieContext.HttpContext.SignOutAsync(CookieAuthenticationDefaults
.AuthenticationScheme);
}
}
};
})
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
options.MetadataAddress = Configuration["oidc:Metadata"];
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.ClientId = Configuration["oidc:ClientId"];
options.ClientSecret = Configuration["oidc:ClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code;
options.CallbackPath = "/oidc-callback";
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("openid");
options.Scope.Add("email");
options.Scope.Add("profile");
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
ValidateAudience = false,
RoleClaimType =
"roles", // using short name here, this is needed because we replace role claims (see code below, OnTicketReceived event)
};
options.SaveTokens = false; //all tokens are stored in session by TokenSourceHelper
options.Events = new OpenIdConnectEvents
{
OnAuthorizationCodeReceived = context =>
{
// pass current session ID to keycloak so it can use if for backchannel requests
if (context.HttpContext.Session != null)
{
var sessionId = context.HttpContext.Session.GetUniqueSessionId();
context.TokenEndpointRequest.Parameters.Add(
TokenSourceHelper.KeycloakSessionStateParameter, sessionId);
//also reset the session ID if it was previously revoked
var logoutSessions = context.HttpContext.RequestServices
.GetRequiredService<ILogoutSessionManager>();
logoutSessions.Remove(null, sessionId);
}
return Task.CompletedTask;
},
//OnTokenValidated =
OnTokenResponseReceived = context =>
{
var tokenSource =
context.HttpContext.RequestServices.GetRequiredService<ITokenSourceHelper>();
tokenSource.SetTokens(context.TokenEndpointResponse.AccessToken,
context.TokenEndpointResponse.RefreshToken, context.TokenEndpointResponse.IdToken);
return Task.CompletedTask;
},
OnTicketReceived = async context =>
{
var tokenSource =
context.HttpContext.RequestServices.GetRequiredService<ITokenSourceHelper>();
var accessToken = await tokenSource.GetToken();
var handler = new JwtSecurityTokenHandler();
var jwtSecurityToken = handler.ReadJwtToken(accessToken);
//reduce the ticket size: shorten claims length
var identity = (ClaimsIdentity)context.Principal?.Identity;
var roles = jwtSecurityToken.Claims.Where(x => x.Type == identity.RoleClaimType);
identity.AddClaims(roles);
},
OnRemoteFailure = context =>
{
//no need to log the exception, it is logged by Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler
context.Response.Redirect("/Home");
context.HandleResponse();
return Task.CompletedTask;
},
OnRedirectToIdentityProviderForSignOut = async context =>
{
if(context.Properties.Items.ContainsKey(WebTime.Business.DTO.Common.Core.Enums.GeneralConstants.TokenHintKey))
{
var idToken =
context.Properties.Items[
GeneralConstants.TokenHintKey];
context.ProtocolMessage.IdTokenHint = idToken;
}
await Task.CompletedTask;
}
};
})
如您所见,我强制从 access_token 读取角色,现在它可以正常工作,但我的 headers 太长了,因为我的 cookie 有 5大块。
也许还有另一种设置可以帮助 access_token 的真实角色声明? 为什么它从 id_token?
读取角色提前致谢。
您使用 AddOpenIdConnect 的客户端不需要 peek/look 访问令牌。
客户端应该只关心id token,如果你想减小会话cookie的大小,那么你可以在IdentityServer中设置AlwaysIncludeUserClaimsInIdToken=false。然后,因为你已经设置 options.GetClaimsFromUserInfoEndpoint = true;然后 AddOpenIdConnect 处理程序将从 UserInfo 端点获取剩余的用户详细信息。
Keycloak 发送具有“角色”声明类型名称的角色,默认情况下 asp.net 核心身份不期望此名称,我使用 options.GetClaimsFromUserInfoEndpoint = true;
添加唯一映射:options.ClaimActions.MapUniqueJsonKey(ClaimsIdentity.DefaultRoleClaimType, "roles");
然后已解析的角色数组,显示为字符串数组