IdentityServer3 和通过 OpenIDConnect 进行外部登录



ASP.NET MVC应用程序中,我正在尝试针对外部OIDC服务实现身份验证。对于我的测试,我使用的是IdentityServer3(https://identityserver.github.io/Documentation/(和公共OIDC演示服务器:https://mitreid.org/

我从 GitHub 克隆了此示例:https://github.com/IdentityServer/IdentityServer3.Samples/tree/master/source/MVC%20Authentication

然后添加以下代码以将公共 OIDC 服务器注册为外部登录提供程序:

private void ConfigureIdentityProviders(IAppBuilder app, string signInAsType)
{
app.UseOpenIdConnectAuthentication(
new OpenIdConnectAuthenticationOptions
{
AuthenticationType = "<AuthTypeName>",
Authority = "https://mitreid.org/",
Caption = "MIT Test Server",
ClientId = "<Client Id>",
ClientSecret = "<Client Secret>",
RedirectUri = "https://localhost:44319/", //NOT SURE WHAT TO PUT HERE
ResponseType = "code",
Scope = "openid email profile",
SignInAsAuthenticationType = signInAsType
});
}

代码有效,我可以选择通过外部 OIDC 服务器登录。浏览器重定向到外部服务器登录页面,输入登录名和密码后,将显示同意页面。但是,在浏览器导航回 https://localhost:44319/用户未进行身份验证 -User.Identity.IsAuthenticated为 false。

问:重定向 Uri 属性的正确值应该是多少?OpenIdConnect 中间件是否能够解析从外部服务器传入的身份验证信息,或者必须手动编码?是否有任何示例代码如何执行此操作?

我正在研究代码和调试几个小时(我是新手(,我了解到:

  • 此问题与Microsoft(https://github.com/aspnet/AspNetKatana/tree/dev/src/Microsoft.Owin.Security.OpenIdConnect(实现的OpenIdConnect OWIN中间件有关。
  • 来自Microsoft的中间件期望OIDC服务器使用HTTP POST发送消息,但MIT服务器使用HTTP GET
  • 来自Microsoft中间件期望从 OIDC 服务器获取的消息中有 id 令牌和代码,但 MIT 服务器只发送代码。
  • 看起来 RedirectUri 可以是/identity 下的任何路径,因为中间件方法 AuthenticateCoreAsync(( 在每个请求上都会命中,并且它会将请求路径与配置的 Options.CallbackPath(从 RedirectURI 设置(进行比较

所以我只需要实现标准的授权代码流 - 将代码交换为 id 令牌,获取声明,创建身份验证票证并重定向到 IdentityServer/identity/callback 端点。当我完成此操作时,一切都开始工作。身份服务器真棒!

我从OpenIdConnect中间件继承了一组新的类,并覆盖了一些方法。密钥方法是async Task<AuthenticationTicket> AuthenticateCoreAsync()OpenIdConnectAuthenticationHandler。我粘贴了下面的代码,以防它对某人有所帮助。

public class CustomOidcHandler : OpenIdConnectAuthenticationHandler
{
private const string HandledResponse = "HandledResponse";
private readonly ILogger _logger;
private OpenIdConnectConfiguration _configuration;
public CustomOidcHandler(ILogger logger) : base(logger)
{
_logger = logger;
}
/// <summary>
/// Invoked to process incoming authentication messages.
/// </summary>
/// <returns>An <see cref="AuthenticationTicket"/> if successful.</returns>
protected override async Task<AuthenticationTicket> AuthenticateCoreAsync()
{
// Allow login to be constrained to a specific path. Need to make this runtime configurable.
if (Options.CallbackPath.HasValue && Options.CallbackPath != (Request.PathBase + Request.Path))
return null;
OpenIdConnectMessage openIdConnectMessage = null;
if (string.Equals(Request.Method, "GET", StringComparison.OrdinalIgnoreCase))
openIdConnectMessage = new OpenIdConnectMessage(Request.Query);
if (openIdConnectMessage == null)
return null;
ExceptionDispatchInfo authFailedEx = null;
try
{
return await CreateAuthenticationTicket(openIdConnectMessage).ConfigureAwait(false);
}
catch (Exception exception)
{
// We can't await inside a catch block, capture and handle outside.
authFailedEx = ExceptionDispatchInfo.Capture(exception);
}
if (authFailedEx != null)
{
_logger.WriteError("Exception occurred while processing message: ", authFailedEx.SourceException);
// Refresh the configuration for exceptions that may be caused by key rollovers. The user can also request a refresh in the notification.
if (Options.RefreshOnIssuerKeyNotFound && authFailedEx.SourceException.GetType() == typeof(SecurityTokenSignatureKeyNotFoundException))
Options.ConfigurationManager.RequestRefresh();
var authenticationFailedNotification = new AuthenticationFailedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions>(Context, Options)
{
ProtocolMessage = openIdConnectMessage,
Exception = authFailedEx.SourceException
};
await Options.Notifications.AuthenticationFailed(authenticationFailedNotification).ConfigureAwait(false);
if (authenticationFailedNotification.HandledResponse)
return GetHandledResponseTicket();
if (authenticationFailedNotification.Skipped)
return null;
authFailedEx.Throw();
}
return null;
}
private async Task<AuthenticationTicket> CreateAuthenticationTicket(OpenIdConnectMessage openIdConnectMessage)
{
var messageReceivedNotification =
new MessageReceivedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions>(Context, Options)
{
ProtocolMessage = openIdConnectMessage
};
await Options.Notifications.MessageReceived(messageReceivedNotification).ConfigureAwait(false);
if (messageReceivedNotification.HandledResponse)
{
return GetHandledResponseTicket();
}
if (messageReceivedNotification.Skipped)
{
return null;
}
// runtime always adds state, if we don't find it OR we failed to 'unprotect' it this is not a message we
// should process.
AuthenticationProperties properties = GetPropertiesFromState(openIdConnectMessage.State);
if (properties == null)
{
_logger.WriteWarning("The state field is missing or invalid.");
return null;
}
// devs will need to hook AuthenticationFailedNotification to avoid having 'raw' runtime errors displayed to users.
if (!string.IsNullOrWhiteSpace(openIdConnectMessage.Error))
{
throw new OpenIdConnectProtocolException(
string.Format(CultureInfo.InvariantCulture,
openIdConnectMessage.Error,
"Exception_OpenIdConnectMessageError", openIdConnectMessage.ErrorDescription ?? string.Empty,
openIdConnectMessage.ErrorUri ?? string.Empty));
}

// tokens.Item1 contains id token
// tokens.Item2 contains access token
Tuple<string, string> tokens = await GetTokens(openIdConnectMessage.Code, Options)
.ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(openIdConnectMessage.IdToken))
openIdConnectMessage.IdToken = tokens.Item1;
var securityTokenReceivedNotification =
new SecurityTokenReceivedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions>(Context,
Options)
{
ProtocolMessage = openIdConnectMessage,
};
await Options.Notifications.SecurityTokenReceived(securityTokenReceivedNotification).ConfigureAwait(false);
if (securityTokenReceivedNotification.HandledResponse)
return GetHandledResponseTicket();
if (securityTokenReceivedNotification.Skipped)
return null;
if (_configuration == null)
_configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.Request.CallCancelled)
.ConfigureAwait(false);
// Copy and augment to avoid cross request race conditions for updated configurations.
TokenValidationParameters tvp = Options.TokenValidationParameters.Clone();
IEnumerable<string> issuers = new[] {_configuration.Issuer};
tvp.ValidIssuers = tvp.ValidIssuers?.Concat(issuers) ?? issuers;
tvp.IssuerSigningTokens = tvp.IssuerSigningTokens?.Concat(_configuration.SigningTokens) ?? _configuration.SigningTokens;
SecurityToken validatedToken;
ClaimsPrincipal principal =
Options.SecurityTokenHandlers.ValidateToken(openIdConnectMessage.IdToken, tvp, out validatedToken);
ClaimsIdentity claimsIdentity = principal.Identity as ClaimsIdentity;
var claims = await GetClaims(tokens.Item2).ConfigureAwait(false);
AddClaim(claims, claimsIdentity, "sub", ClaimTypes.NameIdentifier, Options.AuthenticationType);
AddClaim(claims, claimsIdentity, "given_name", ClaimTypes.GivenName);
AddClaim(claims, claimsIdentity, "family_name", ClaimTypes.Surname);
AddClaim(claims, claimsIdentity, "preferred_username", ClaimTypes.Name);
AddClaim(claims, claimsIdentity, "email", ClaimTypes.Email);
// claims principal could have changed claim values, use bits received on wire for validation.
JwtSecurityToken jwt = validatedToken as JwtSecurityToken;
AuthenticationTicket ticket = new AuthenticationTicket(claimsIdentity, properties);
if (Options.ProtocolValidator.RequireNonce)
{
if (String.IsNullOrWhiteSpace(openIdConnectMessage.Nonce))
openIdConnectMessage.Nonce = jwt.Payload.Nonce;
// deletes the nonce cookie
RetrieveNonce(openIdConnectMessage);
}
// remember 'session_state' and 'check_session_iframe'
if (!string.IsNullOrWhiteSpace(openIdConnectMessage.SessionState))
ticket.Properties.Dictionary[OpenIdConnectSessionProperties.SessionState] = openIdConnectMessage.SessionState;
if (!string.IsNullOrWhiteSpace(_configuration.CheckSessionIframe))
ticket.Properties.Dictionary[OpenIdConnectSessionProperties.CheckSessionIFrame] =
_configuration.CheckSessionIframe;
if (Options.UseTokenLifetime)
{
// Override any session persistence to match the token lifetime.
DateTime issued = jwt.ValidFrom;
if (issued != DateTime.MinValue)
{
ticket.Properties.IssuedUtc = issued.ToUniversalTime();
}
DateTime expires = jwt.ValidTo;
if (expires != DateTime.MinValue)
{
ticket.Properties.ExpiresUtc = expires.ToUniversalTime();
}
ticket.Properties.AllowRefresh = false;
}
var securityTokenValidatedNotification =
new SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions>(Context,
Options)
{
AuthenticationTicket = ticket,
ProtocolMessage = openIdConnectMessage,
};
await Options.Notifications.SecurityTokenValidated(securityTokenValidatedNotification).ConfigureAwait(false);
if (securityTokenValidatedNotification.HandledResponse)
{
return GetHandledResponseTicket();
}
if (securityTokenValidatedNotification.Skipped)
{
return null;
}
// Flow possible changes
ticket = securityTokenValidatedNotification.AuthenticationTicket;
// there is no hash of the code (c_hash) in the jwt obtained from the server
// I don't know how to perform the validation using ProtocolValidator without the hash
// that is why the code below is commented
//var protocolValidationContext = new OpenIdConnectProtocolValidationContext
//{
//    AuthorizationCode = openIdConnectMessage.Code,
//    Nonce = nonce
//};
//Options.ProtocolValidator.Validate(jwt, protocolValidationContext);
if (openIdConnectMessage.Code != null)
{
var authorizationCodeReceivedNotification = new AuthorizationCodeReceivedNotification(Context, Options)
{
AuthenticationTicket = ticket,
Code = openIdConnectMessage.Code,
JwtSecurityToken = jwt,
ProtocolMessage = openIdConnectMessage,
RedirectUri =
ticket.Properties.Dictionary.ContainsKey(OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey)
? ticket.Properties.Dictionary[OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey]
: string.Empty,
};
await Options.Notifications.AuthorizationCodeReceived(authorizationCodeReceivedNotification)
.ConfigureAwait(false);
if (authorizationCodeReceivedNotification.HandledResponse)
{
return GetHandledResponseTicket();
}
if (authorizationCodeReceivedNotification.Skipped)
{
return null;
}
// Flow possible changes
ticket = authorizationCodeReceivedNotification.AuthenticationTicket;
}
return ticket;
}
private static void AddClaim(IEnumerable<Tuple<string, string>> claims, ClaimsIdentity claimsIdentity, string key, string claimType, string issuer = null)
{
string subject = claims
.Where(it => it.Item1 == key)
.Select(x => x.Item2).SingleOrDefault();
if (!string.IsNullOrWhiteSpace(subject))
claimsIdentity.AddClaim(
new System.Security.Claims.Claim(claimType, subject, ClaimValueTypes.String, issuer));
}

private async Task<Tuple<string, string>> GetTokens(string authorizationCode, OpenIdConnectAuthenticationOptions options)
{
// exchange authorization code at authorization server for an access and refresh token
Dictionary<string, string> post = null;
post = new Dictionary<string, string>
{
{"client_id", options.ClientId},
{"client_secret", options.ClientSecret},
{"grant_type", "authorization_code"},
{"code", authorizationCode},
{"redirect_uri", options.RedirectUri}
};
string content;
using (var client = new HttpClient())
{
var postContent = new FormUrlEncodedContent(post);
var response = await client.PostAsync(options.Authority.TrimEnd('/') + "/token", postContent)
.ConfigureAwait(false);
content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
// received tokens from authorization server
var json = JObject.Parse(content);
var accessToken = json["access_token"].ToString();
string idToken = null;
if (json["id_token"] != null)
idToken = json["id_token"].ToString();
return new Tuple<string, string>(idToken, accessToken);
}
private async Task<IEnumerable<Tuple<string, string>>> GetClaims(string accessToken)
{
string userInfoEndpoint = Options.Authority.TrimEnd('/') + "/userinfo";
var userInfoClient = new UserInfoClient(new Uri(userInfoEndpoint), accessToken);
var userInfoResponse = await userInfoClient.GetAsync().ConfigureAwait(false);
var claims = userInfoResponse.Claims;
return claims;
}
private static AuthenticationTicket GetHandledResponseTicket()
{
return new AuthenticationTicket(null, new AuthenticationProperties(new Dictionary<string, string>() { { HandledResponse, "true" } }));
}
private AuthenticationProperties GetPropertiesFromState(string state)
{
// assume a well formed query string: <a=b&>OpenIdConnectAuthenticationDefaults.AuthenticationPropertiesKey=kasjd;fljasldkjflksdj<&c=d>
int startIndex = 0;
if (string.IsNullOrWhiteSpace(state) || (startIndex = state.IndexOf("OpenIdConnect.AuthenticationProperties", StringComparison.Ordinal)) == -1)
{
return null;
}
int authenticationIndex = startIndex + "OpenIdConnect.AuthenticationProperties".Length;
if (authenticationIndex == -1 || authenticationIndex == state.Length || state[authenticationIndex] != '=')
{
return null;
}
// scan rest of string looking for '&'
authenticationIndex++;
int endIndex = state.Substring(authenticationIndex, state.Length - authenticationIndex).IndexOf("&", StringComparison.Ordinal);
// -1 => no other parameters are after the AuthenticationPropertiesKey
if (endIndex == -1)
{
return Options.StateDataFormat.Unprotect(Uri.UnescapeDataString(state.Substring(authenticationIndex).Replace('+', ' ')));
}
else
{
return Options.StateDataFormat.Unprotect(Uri.UnescapeDataString(state.Substring(authenticationIndex, endIndex).Replace('+', ' ')));
}
}
}

public static class CustomOidcAuthenticationExtensions
{
/// <summary>
/// Adds the <see cref="OpenIdConnectAuthenticationMiddleware"/> into the OWIN runtime.
/// </summary>
/// <param name="app">The <see cref="IAppBuilder"/> passed to the configuration method</param>
/// <param name="openIdConnectOptions">A <see cref="OpenIdConnectAuthenticationOptions"/> contains settings for obtaining identities using the OpenIdConnect protocol.</param>
/// <returns>The updated <see cref="IAppBuilder"/></returns>
public static IAppBuilder UseCustomOidcAuthentication(this IAppBuilder app, OpenIdConnectAuthenticationOptions openIdConnectOptions)
{
if (app == null)
throw new ArgumentNullException(nameof(app));
if (openIdConnectOptions == null)
throw new ArgumentNullException(nameof(openIdConnectOptions));
return app.Use(typeof(CustomOidcMiddleware), app, openIdConnectOptions);
}
}

和启动.cs

public class Startup
{
....
public void Configuration(IAppBuilder app)
{
....
private void ConfigureIdentityProviders(IAppBuilder app, string signInAsType)
{
app.UseCustomOidcAuthentication(
new OpenIdConnectAuthenticationOptions
{
AuthenticationType = "<name>",
Authority = "<OIDC server url>",
Caption = "<caption>",
ClientId = "<client id>",
ClientSecret = "<client secret>",
// might be https://localhost:44319/identity/<anything>
RedirectUri = "https://localhost:44319/identity/signin-customoidc",
ResponseType = "code",
Scope = "openid email profile address phone",
SignInAsAuthenticationType = signInAsType
}                
);
}
....
}
....
}

最新更新