IdentityServer3 和通过 OpenIDConnect 进行外部登录

Posted

技术标签:

【中文标题】IdentityServer3 和通过 OpenIDConnect 进行外部登录【英文标题】:IdentityServer3 and external login through OpenIDConnect 【发布时间】:2017-12-10 09:04:48 【问题描述】:

在 ASP.NET MVC 应用程序中,我正在尝试针对外部 OIDC 服务实施身份验证。对于我的测试,我使用 IdentityServer3 (https://identityserver.github.io/Documentation/) 和 public 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 为假。

问题: RedirectUri 属性的正确值应该是多少? OpenIdConnect 中间件是否能够解析从外部服务器传入的身份验证信息,或者必须手动编码?有没有示例代码如何做到这一点?

【问题讨论】:

【参考方案1】:

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

此问题与 Microsoft (https://github.com/aspnet/AspNetKatana/tree/dev/src/Microsoft.Owin.Security.OpenIdConnect) 实现的 OpenIdConnect OWIN 中间件有关。 来自 Microsoft 的中间件期望 OIDC 服务器 sends the message using HTTP POST,但 MIT 服务器执行 HTTP GET 微软expect that there is id token along with code in the message 的中间件从OIDC服务器获取,但MIT服务器只发送代码。 看起来 RedirectUri 可以是 /identity 下的任何路径,因为中间件方法 AuthenticateCoreAsync() 在每个请求上都会被命中,并且它 does compare request path to configured Options.CallbackPath(从 RedirectURI 设置)

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

我从 OpenIdConnect 中间件继承了一组新的类,并且确实覆盖了一些方法。关键方法是OpenIdConnectAuthenticationHandler中的async Task&lt;AuthenticationTicket&gt; AuthenticateCoreAsync()。我粘贴了下面的代码,以防它对某人有帮助。

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);
    

在 Startup.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
                            
        );
    
    ....

....

【讨论】:

这完美地工作(经过一些简单的修改)将 AWS Cognito 用作外部 IdP。 issue #2904 中的方法完全失败了。谢谢! @spatialguy:感谢您的帖子。你有这个在 github 之类的吗? @spatialguy:我猜你不记得你将更改应用到AspNetKatana 中的哪个标签或提交? 对于授权码流程,我想知道您是否可以通过以下方式避免自定义OIDC中间件的需要:(1)使用MessageReceived通知交换令牌的代码,并填充id令牌进入ProtocolMessage; (2) 提供一个自定义的OpenIdConnectProtocolValidator 子类,如果c_hash 不存在则跳过验证。 如果外部 IdP 支持混合流,那么如果 response_type 包含 id_token,OWIN OIDC 中间件应该开箱即用。

以上是关于IdentityServer3 和通过 OpenIDConnect 进行外部登录的主要内容,如果未能解决你的问题,请参考以下文章

四种BI 开源工具介绍-SpagoBI,openI,JasperSoft,Pentaho

如何使用IdentityServer3进行登录吗

一步一步学习IdentityServer3 (13) 令牌

使用 IdentityServer3 生成访问令牌,无需密码

IdentityServer3 + AzureAD 和 RedirectUri 混淆

Identityserver3 可以作为 SAML(C#、MVC)的身份提供者吗?