当前位置: 移动技术网 > IT编程>开发语言>.net > AspNetCore3.1_Secutiry源码解析_6_Authentication_OpenIdConnect

AspNetCore3.1_Secutiry源码解析_6_Authentication_OpenIdConnect

2020年03月29日  | 移动技术网IT编程  | 我要评论

黄岛人才市场,金钱帝国演员表,塞维娜·布林森

目录

oidc简介

oidc是基于oauth2.0的上层协议。

oauth有点像卖电影票的,只关心用户能不能进电影院,不关心用户是谁。而oidc则像身份证,扫描就可以上飞机,一次扫描,机场不仅能知道你是否能上飞机,还可以知道你的身份信息。

oidc兼容oauth2.0, 可以实现跨顶级域的sso(单点登录、登出),下个系列要学习的identityserver4就是对oidc协议族的一个具体实现框架。

更多理论知识看下面的参考资料,本系列主要过下源码脉络

博客园

协议

依赖注入

默认架构名称是openidconnect,处理器类是openidconnecthandler,配置类是openidconnectoptions

public static authenticationbuilder addopenidconnect(this authenticationbuilder builder)
        => builder.addopenidconnect(openidconnectdefaults.authenticationscheme, _ => { });

    public static authenticationbuilder addopenidconnect(this authenticationbuilder builder, action<openidconnectoptions> configureoptions)
        => builder.addopenidconnect(openidconnectdefaults.authenticationscheme, configureoptions);

    public static authenticationbuilder addopenidconnect(this authenticationbuilder builder, string authenticationscheme, action<openidconnectoptions> configureoptions)
        => builder.addopenidconnect(authenticationscheme, openidconnectdefaults.displayname, configureoptions);

    public static authenticationbuilder addopenidconnect(this authenticationbuilder builder, string authenticationscheme, string displayname, action<openidconnectoptions> configureoptions)
    {
        builder.services.tryaddenumerable(servicedescriptor.singleton<ipostconfigureoptions<openidconnectoptions>, openidconnectpostconfigureoptions>());
        return builder.addremotescheme<openidconnectoptions, openidconnecthandler>(authenticationscheme, displayname, configureoptions);
    }

配置类 - openidconnectoptions

构造函数

callbackpath: 回调地址,即远程认证之后跳回的地址
signedoutcallbackpath:登出后的回调地址
remotesignoutpath:远程登出地址

scope添加openid(用户id),profile(用户基本信息),所以如果client没有这两个基本的权限是会被远程认证拒绝的。

删除了nonce,aud等claim,添加了sub(用户id,必须有),name,profile,email等claim。mapuniquejsonkey方法的意思是如果某claim无值,远程认证服务返回的用户json数据中中存在此key且有值,则将值插入claim中,否则什么也不做。

然后new了防重放攻击的nonce cookie。

public openidconnectoptions()
{
    callbackpath = new pathstring("/signin-oidc");
    signedoutcallbackpath = new pathstring("/signout-callback-oidc");
    remotesignoutpath = new pathstring("/signout-oidc");

    events = new openidconnectevents();
    scope.add("openid");
    scope.add("profile");

    claimactions.deleteclaim("nonce");
    claimactions.deleteclaim("aud");
    claimactions.deleteclaim("azp");
    claimactions.deleteclaim("acr");
    claimactions.deleteclaim("iss");
    claimactions.deleteclaim("iat");
    claimactions.deleteclaim("nbf");
    claimactions.deleteclaim("exp");
    claimactions.deleteclaim("at_hash");
    claimactions.deleteclaim("c_hash");
    claimactions.deleteclaim("ipaddr");
    claimactions.deleteclaim("platf");
    claimactions.deleteclaim("ver");

    // http://openid.net/specs/openid-connect-core-1_0.html#standardclaims
    claimactions.mapuniquejsonkey("sub", "sub");
    claimactions.mapuniquejsonkey("name", "name");
    claimactions.mapuniquejsonkey("given_name", "given_name");
    claimactions.mapuniquejsonkey("family_name", "family_name");
    claimactions.mapuniquejsonkey("profile", "profile");
    claimactions.mapuniquejsonkey("email", "email");

    _noncecookiebuilder = new openidconnectnoncecookiebuilder(this)
    {
        name = openidconnectdefaults.cookienonceprefix,
        httponly = true,
        samesite = samesitemode.none,
        securepolicy = cookiesecurepolicy.sameasrequest,
        isessential = true,
    };
}

配置校验 - validate

父类remoteauthenticationoptions会校验signinschema不允许与当前schema相同(signinschema微软只提供了cookie的实现,登录似乎除了cookie没有别的方式可以维持登录态?)

校验max-age不能为负数

clientid不能为空

callbackpath必须有值

configurationmanager不能为null

public override void validate()
{
    base.validate();

    if (maxage.hasvalue && maxage.value < timespan.zero)
    {
        throw new argumentoutofrangeexception(nameof(maxage), maxage.value, "the value must not be a negative timespan.");
    }

    if (string.isnullorempty(clientid))
    {
        throw new argumentexception("options.clientid must be provided", nameof(clientid));
    }

    if (!callbackpath.hasvalue)
    {
        throw new argumentexception("options.callbackpath must be provided.", nameof(callbackpath));
    }

    if (configurationmanager == null)
    {
        throw new invalidoperationexception($"provide {nameof(authority)}, {nameof(metadataaddress)}, "
        + $"{nameof(configuration)}, or {nameof(configurationmanager)} to {nameof(openidconnectoptions)}");
    }
}

属性

/// <summary>
/// gets or sets timeout value in milliseconds for back channel communications with the remote identity provider.
/// </summary>
/// <value>
/// the back channel timeout.
/// </value>
public timespan backchanneltimeout { get; set; } = timespan.fromseconds(60);

/// <summary>
/// the httpmessagehandler used to communicate with remote identity provider.
/// this cannot be set at the same time as backchannelcertificatevalidator unless the value 
/// can be downcast to a webrequesthandler.
/// </summary>
public httpmessagehandler backchannelhttphandler { get; set; }

/// <summary>
/// used to communicate with the remote identity provider.
/// </summary>
public httpclient backchannel { get; set; }

/// <summary>
/// gets or sets the type used to secure data.
/// </summary>
public idataprotectionprovider dataprotectionprovider { get; set; }

/// <summary>
/// the request path within the application's base path where the user-agent will be returned.
/// the middleware will process this request when it arrives.
/// </summary>
public pathstring callbackpath { get; set; }

/// <summary>
/// gets or sets the optional path the user agent is redirected to if the user
/// doesn't approve the authorization demand requested by the remote server.
/// this property is not set by default. in this case, an exception is thrown
/// if an access_denied response is returned by the remote authorization server.
/// </summary>
public pathstring accessdeniedpath { get; set; }

/// <summary>
/// gets or sets the name of the parameter used to convey the original location
/// of the user before the remote challenge was triggered up to the access denied page.
/// this property is only used when the <see cref="accessdeniedpath"/> is explicitly specified.
/// </summary>
// note: this deliberately matches the default parameter name used by the cookie handler.
public string returnurlparameter { get; set; } = "returnurl";

/// <summary>
/// gets or sets the authentication scheme corresponding to the middleware
/// responsible of persisting user's identity after a successful authentication.
/// this value typically corresponds to a cookie middleware registered in the startup class.
/// when omitted, <see cref="authenticationoptions.defaultsigninscheme"/> is used as a fallback value.
/// </summary>
public string signinscheme { get; set; }

/// <summary>
/// gets or sets the time limit for completing the authentication flow (15 minutes by default).
/// </summary>
public timespan remoteauthenticationtimeout { get; set; } = timespan.fromminutes(15);

public new remoteauthenticationevents events
{
    get => (remoteauthenticationevents)base.events;
    set => base.events = value;
}

/// <summary>
/// defines whether access and refresh tokens should be stored in the
/// <see cref="authenticationproperties"/> after a successful authorization.
/// this property is set to <c>false</c> by default to reduce
/// the size of the final authentication cookie.
/// </summary>
public bool savetokens { get; set; }

/// <summary>
/// determines the settings used to create the correlation cookie before the
/// cookie gets added to the response.
/// </summary>
public cookiebuilder correlationcookie
{
    get => _correlationcookiebuilder;
    set => _correlationcookiebuilder = value ?? throw new argumentnullexception(nameof(value));
}

配置后处理逻辑 - openidconnectpostconfigureoptions

主要处理如果dataprotectionprovider,statedataformat等对象没有配置的话,则构造默认实现类。options.metadataaddress += ".well-known/openid-configuration",这是配置的元数据地址,描述了oidc的所有接口地址和其他信息。

public class openidconnectpostconfigureoptions : ipostconfigureoptions<openidconnectoptions>
{
    private readonly idataprotectionprovider _dp;

    public openidconnectpostconfigureoptions(idataprotectionprovider dataprotection)
    {
        _dp = dataprotection;
    }

    /// <summary>
    /// invoked to post configure a toptions instance.
    /// </summary>
    /// <param name="name">the name of the options instance being configured.</param>
    /// <param name="options">the options instance to configure.</param>
    public void postconfigure(string name, openidconnectoptions options)
    {
        options.dataprotectionprovider = options.dataprotectionprovider ?? _dp;

        if (string.isnullorempty(options.signoutscheme))
        {
            options.signoutscheme = options.signinscheme;
        }

        if (options.statedataformat == null)
        {
            var dataprotector = options.dataprotectionprovider.createprotector(
                typeof(openidconnecthandler).fullname, name, "v1");
            options.statedataformat = new propertiesdataformat(dataprotector);
        }

        if (options.stringdataformat == null)
        {
            var dataprotector = options.dataprotectionprovider.createprotector(
                typeof(openidconnecthandler).fullname,
                typeof(string).fullname,
                name,
                "v1");

            options.stringdataformat = new securedataformat<string>(new stringserializer(), dataprotector);
        }

        if (string.isnullorempty(options.tokenvalidationparameters.validaudience) && !string.isnullorempty(options.clientid))
        {
            options.tokenvalidationparameters.validaudience = options.clientid;
        }

        if (options.backchannel == null)
        {
            options.backchannel = new httpclient(options.backchannelhttphandler ?? new httpclienthandler());
            options.backchannel.defaultrequestheaders.useragent.parseadd("microsoft asp.net core openidconnect handler");
            options.backchannel.timeout = options.backchanneltimeout;
            options.backchannel.maxresponsecontentbuffersize = 1024 * 1024 * 10; // 10 mb
        }

        if (options.configurationmanager == null)
        {
            if (options.configuration != null)
            {
                options.configurationmanager = new staticconfigurationmanager<openidconnectconfiguration>(options.configuration);
            }
            else if (!(string.isnullorempty(options.metadataaddress) && string.isnullorempty(options.authority)))
            {
                if (string.isnullorempty(options.metadataaddress) && !string.isnullorempty(options.authority))
                {
                    options.metadataaddress = options.authority;
                    if (!options.metadataaddress.endswith("/", stringcomparison.ordinal))
                    {
                        options.metadataaddress += "/";
                    }

                    options.metadataaddress += ".well-known/openid-configuration";
                }

                if (options.requirehttpsmetadata && !options.metadataaddress.startswith("https://", stringcomparison.ordinalignorecase))
                {
                    throw new invalidoperationexception("the metadataaddress or authority must use https unless disabled for development by setting requirehttpsmetadata=false.");
                }

                options.configurationmanager = new configurationmanager<openidconnectconfiguration>(options.metadataaddress, new openidconnectconfigurationretriever(),
                    new httpdocumentretriever(options.backchannel) { requirehttps = options.requirehttpsmetadata });
            }
        }
    }

    private class stringserializer : idataserializer<string>
    {
        public string deserialize(byte[] data)
        {
            return encoding.utf8.getstring(data);
        }

        public byte[] serialize(string model)
        {
            return encoding.utf8.getbytes(model);
        }
    }

处理器类 - openidconnecthandler

处理认证 - handremoteauthenticate

oidc登录示例图

sequencediagram mysite->>sso: get connect/authorize?callback(clientid,redirect_uri,response_type)scope,state,nonce sso->>mysite: form.post mysite/signin-oidc (code,id_token,scope,state)

代码解析

mysite向oidc的认证节点地址/connect/authorize发送请求,oidc站点根据response_mode用get或者form_post方式调用mysite的回调地址mysite/signin-oidc,handleremoteauthenticateasync就是处理oidc站点的响应的方法。

  • 判断get/post,从请求中提取参数,如果是get请求,id_token,access_token不允许放在query中
  • 从state参数读取信息放到properties
  • 校验correlationid,防跨站伪造攻击
  • 如果返回了id_token,校验token,将信息写入httpcontext
  • 如果返回了授权码code的处理

代码量还是比较多,有些地方目前还不是特别理解,需求后面熟悉协议内容在回过头来看下。总体上就是对oidc站点返回信息的校验和处理。

/// <summary>
/// invoked to process incoming openidconnect messages.
/// </summary>
/// <returns>an <see cref="handlerequestresult"/>.</returns>
protected override async task<handlerequestresult> handleremoteauthenticateasync()
{
    logger.enteringopenidauthenticationhandlerhandleremoteauthenticateasync(gettype().fullname);

    openidconnectmessage authorizationresponse = null;

    if (string.equals(request.method, "get", stringcomparison.ordinalignorecase))
    {
        authorizationresponse = new openidconnectmessage(request.query.select(pair => new keyvaluepair<string, string[]>(pair.key, pair.value)));

        // response_mode=query (explicit or not) and a response_type containing id_token
        // or token are not considered as a safe combination and must be rejected.
        // see http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#security
        if (!string.isnullorempty(authorizationresponse.idtoken) || !string.isnullorempty(authorizationresponse.accesstoken))
        {
            if (options.skipunrecognizedrequests)
            {
                // not for us?
                return handlerequestresult.skiphandler();
            }
            return handlerequestresult.fail("an openid connect response cannot contain an " +
                    "identity token or an access token when using response_mode=query");
        }
    }
    // assumption: if the contenttype is "application/x-www-form-urlencoded" it should be safe to read as it is small.
    else if (string.equals(request.method, "post", stringcomparison.ordinalignorecase)
        && !string.isnullorempty(request.contenttype)
        // may have media/type; charset=utf-8, allow partial match.
        && request.contenttype.startswith("application/x-www-form-urlencoded", stringcomparison.ordinalignorecase)
        && request.body.canread)
    {
        var form = await request.readformasync();
        authorizationresponse = new openidconnectmessage(form.select(pair => new keyvaluepair<string, string[]>(pair.key, pair.value)));
    }

    if (authorizationresponse == null)
    {
        if (options.skipunrecognizedrequests)
        {
            // not for us?
            return handlerequestresult.skiphandler();
        }
        return handlerequestresult.fail("no message.");
    }

    authenticationproperties properties = null;
    try
    {
        properties = readpropertiesandclearstate(authorizationresponse);

        var messagereceivedcontext = await runmessagereceivedeventasync(authorizationresponse, properties);
        if (messagereceivedcontext.result != null)
        {
            return messagereceivedcontext.result;
        }
        authorizationresponse = messagereceivedcontext.protocolmessage;
        properties = messagereceivedcontext.properties;

        if (properties == null || properties.items.count == 0)
        {
            // fail if state is missing, it's required for the correlation id.
            if (string.isnullorempty(authorizationresponse.state))
            {
                // this wasn't a valid oidc message, it may not have been intended for us.
                logger.nulloremptyauthorizationresponsestate();
                if (options.skipunrecognizedrequests)
                {
                    return handlerequestresult.skiphandler();
                }
                return handlerequestresult.fail(resources.messagestateisnullorempty);
            }

            properties = readpropertiesandclearstate(authorizationresponse);
        }

        if (properties == null)
        {
            logger.unabletoreadauthorizationresponsestate();
            if (options.skipunrecognizedrequests)
            {
                // not for us?
                return handlerequestresult.skiphandler();
            }

            // if state exists and we failed to 'unprotect' this is not a message we should process.
            return handlerequestresult.fail(resources.messagestateisinvalid);
        }

        if (!validatecorrelationid(properties))
        {
            return handlerequestresult.fail("correlation failed.", properties);
        }

        // if any of the error fields are set, throw error null
        if (!string.isnullorempty(authorizationresponse.error))
        {
            // note: access_denied errors are special protocol errors indicating the user didn't
            // approve the authorization demand requested by the remote authorization server.
            // since it's a frequent scenario (that is not caused by incorrect configuration),
            // denied errors are handled differently using handleaccessdeniederrorasync().
            // visit https://tools.ietf.org/html/rfc6749#section-4.1.2.1 for more information.
            if (string.equals(authorizationresponse.error, "access_denied", stringcomparison.ordinal))
            {
                var result = await handleaccessdeniederrorasync(properties);
                if (!result.none)
                {
                    return result;
                }
            }

            return handlerequestresult.fail(createopenidconnectprotocolexception(authorizationresponse, response: null), properties);
        }

        if (_configuration == null && options.configurationmanager != null)
        {
            logger.updatingconfiguration();
            _configuration = await options.configurationmanager.getconfigurationasync(context.requestaborted);
        }

        populatesessionproperties(authorizationresponse, properties);

        claimsprincipal user = null;
        jwtsecuritytoken jwt = null;
        string nonce = null;
        var validationparameters = options.tokenvalidationparameters.clone();

        // hybrid or implicit flow
        if (!string.isnullorempty(authorizationresponse.idtoken))
        {
            logger.receivedidtoken();
            user = validatetoken(authorizationresponse.idtoken, properties, validationparameters, out jwt);

            nonce = jwt.payload.nonce;
            if (!string.isnullorempty(nonce))
            {
                nonce = readnoncecookie(nonce);
            }

            var tokenvalidatedcontext = await runtokenvalidatedeventasync(authorizationresponse, null, user, properties, jwt, nonce);
            if (tokenvalidatedcontext.result != null)
            {
                return tokenvalidatedcontext.result;
            }
            authorizationresponse = tokenvalidatedcontext.protocolmessage;
            user = tokenvalidatedcontext.principal;
            properties = tokenvalidatedcontext.properties;
            jwt = tokenvalidatedcontext.securitytoken;
            nonce = tokenvalidatedcontext.nonce;
        }

        options.protocolvalidator.validateauthenticationresponse(new openidconnectprotocolvalidationcontext()
        {
            clientid = options.clientid,
            protocolmessage = authorizationresponse,
            validatedidtoken = jwt,
            nonce = nonce
        });

        openidconnectmessage tokenendpointresponse = null;

        // authorization code or hybrid flow
        if (!string.isnullorempty(authorizationresponse.code))
        {
            var authorizationcodereceivedcontext = await runauthorizationcodereceivedeventasync(authorizationresponse, user, properties, jwt);
            if (authorizationcodereceivedcontext.result != null)
            {
                return authorizationcodereceivedcontext.result;
            }
            authorizationresponse = authorizationcodereceivedcontext.protocolmessage;
            user = authorizationcodereceivedcontext.principal;
            properties = authorizationcodereceivedcontext.properties;
            var tokenendpointrequest = authorizationcodereceivedcontext.tokenendpointrequest;
            // if the developer redeemed the code themselves...
            tokenendpointresponse = authorizationcodereceivedcontext.tokenendpointresponse;
            jwt = authorizationcodereceivedcontext.jwtsecuritytoken;

            if (!authorizationcodereceivedcontext.handledcoderedemption)
            {
                tokenendpointresponse = await redeemauthorizationcodeasync(tokenendpointrequest);
            }

            var tokenresponsereceivedcontext = await runtokenresponsereceivedeventasync(authorizationresponse, tokenendpointresponse, user, properties);
            if (tokenresponsereceivedcontext.result != null)
            {
                return tokenresponsereceivedcontext.result;
            }

            authorizationresponse = tokenresponsereceivedcontext.protocolmessage;
            tokenendpointresponse = tokenresponsereceivedcontext.tokenendpointresponse;
            user = tokenresponsereceivedcontext.principal;
            properties = tokenresponsereceivedcontext.properties;

            // no need to validate signature when token is received using "code flow" as per spec
            // [http://openid.net/specs/openid-connect-core-1_0.html#idtokenvalidation].
            validationparameters.requiresignedtokens = false;

            // at least a cursory validation is required on the new idtoken, even if we've already validated the one from the authorization response.
            // and we'll want to validate the new jwt in validatetokenresponse.
            var tokenendpointuser = validatetoken(tokenendpointresponse.idtoken, properties, validationparameters, out var tokenendpointjwt);

            // avoid reading & deleting the nonce cookie, running the event, etc, if it was already done as part of the authorization response validation.
            if (user == null)
            {
                nonce = tokenendpointjwt.payload.nonce;
                if (!string.isnullorempty(nonce))
                {
                    nonce = readnoncecookie(nonce);
                }

                var tokenvalidatedcontext = await runtokenvalidatedeventasync(authorizationresponse, tokenendpointresponse, tokenendpointuser, properties, tokenendpointjwt, nonce);
                if (tokenvalidatedcontext.result != null)
                {
                    return tokenvalidatedcontext.result;
                }
                authorizationresponse = tokenvalidatedcontext.protocolmessage;
                tokenendpointresponse = tokenvalidatedcontext.tokenendpointresponse;
                user = tokenvalidatedcontext.principal;
                properties = tokenvalidatedcontext.properties;
                jwt = tokenvalidatedcontext.securitytoken;
                nonce = tokenvalidatedcontext.nonce;
            }
            else
            {
                if (!string.equals(jwt.subject, tokenendpointjwt.subject, stringcomparison.ordinal))
                {
                    throw new securitytokenexception("the sub claim does not match in the id_token's from the authorization and token endpoints.");
                }

                jwt = tokenendpointjwt;
            }

            // validate the token response if it wasn't provided manually
            if (!authorizationcodereceivedcontext.handledcoderedemption)
            {
                options.protocolvalidator.validatetokenresponse(new openidconnectprotocolvalidationcontext()
                {
                    clientid = options.clientid,
                    protocolmessage = tokenendpointresponse,
                    validatedidtoken = jwt,
                    nonce = nonce
                });
            }
        }

        if (options.savetokens)
        {
            savetokens(properties, tokenendpointresponse ?? authorizationresponse);
        }

        if (options.getclaimsfromuserinfoendpoint)
        {
            return await getuserinformationasync(tokenendpointresponse ?? authorizationresponse, jwt, user, properties);
        }
        else
        {
            using (var payload = jsondocument.parse("{}"))
            {
                var identity = (claimsidentity)user.identity;
                foreach (var action in options.claimactions)
                {
                    action.run(payload.rootelement, identity, claimsissuer);
                }
            }
        }

        return handlerequestresult.success(new authenticationticket(user, properties, scheme.name));
    }
    catch (exception exception)
    {
        logger.exceptionprocessingmessage(exception);

        // refresh the configuration for exceptions that may be caused by key rollovers. the user can also request a refresh in the event.
        if (options.refreshonissuerkeynotfound && exception is securitytokensignaturekeynotfoundexception)
        {
            if (options.configurationmanager != null)
            {
                logger.configurationmanagerrequestrefreshcalled();
                options.configurationmanager.requestrefresh();
            }
        }

        var authenticationfailedcontext = await runauthenticationfailedeventasync(authorizationresponse, exception);
        if (authenticationfailedcontext.result != null)
        {
            return authenticationfailedcontext.result;
        }

        return handlerequestresult.fail(exception, properties);
    }
}

处理远程登出 - handleremotesignoutasync

openidconecthandler跟oauthhandler一样,继承自remoteauthenticationhandler,但是openid还实现了iauthenticationsignouthandler接口,因为openid是支持单点登录登出的,本地登出之后需要通知认证服务远程登出(注销本地站点cookie),这样实现帐号的同步登出(注销sso站点cookie)。

  • 远程登出支持get和form-post两种提交方式,客户端根据请求方式,将报文拼装好。
  • 触发远程登出事件
  • 使用signoutscheme认证,得到身份信息 - context.authenticateasync(options.signoutscheme)
  • context.proerties中必须有iss信息,issuer就是提供认证方
  • 调用本地登出方法 - context.signoutasync(options.signoutscheme)
protected virtual async task<bool> handleremotesignoutasync()
{
    openidconnectmessage message = null;

    if (string.equals(request.method, "get", stringcomparison.ordinalignorecase))
    {
        message = new openidconnectmessage(request.query.select(pair => new keyvaluepair<string, string[]>(pair.key, pair.value)));
    }

    // assumption: if the contenttype is "application/x-www-form-urlencoded" it should be safe to read as it is small.
    else if (string.equals(request.method, "post", stringcomparison.ordinalignorecase)
        && !string.isnullorempty(request.contenttype)
        // may have media/type; charset=utf-8, allow partial match.
        && request.contenttype.startswith("application/x-www-form-urlencoded", stringcomparison.ordinalignorecase)
        && request.body.canread)
    {
        var form = await request.readformasync();
        message = new openidconnectmessage(form.select(pair => new keyvaluepair<string, string[]>(pair.key, pair.value)));
    }

    var remotesignoutcontext = new remotesignoutcontext(context, scheme, options, message);
    await events.remotesignout(remotesignoutcontext);

    if (remotesignoutcontext.result != null)
    {
        if (remotesignoutcontext.result.handled)
        {
            logger.remotesignouthandledresponse();
            return true;
        }
        if (remotesignoutcontext.result.skipped)
        {
            logger.remotesignoutskipped();
            return false;
        }
        if (remotesignoutcontext.result.failure != null)
        {
            throw new invalidoperationexception("an error was returned from the remotesignout event.", remotesignoutcontext.result.failure);
        }
    }

    if (message == null)
    {
        return false;
    }

    // try to extract the session identifier from the authentication ticket persisted by the sign-in handler.
    // if the identifier cannot be found, bypass the session identifier checks: this may indicate that the
    // authentication cookie was already cleared, that the session identifier was lost because of a lossy
    // external/application cookie conversion or that the identity provider doesn't support sessions.
    var principal = (await context.authenticateasync(options.signoutscheme))?.principal;

    var sid = principal?.findfirst(jwtregisteredclaimnames.sid)?.value;
    if (!string.isnullorempty(sid))
    {
        // ensure a 'sid' parameter was sent by the identity provider.
        if (string.isnullorempty(message.sid))
        {
            logger.remotesignoutsessionidmissing();
            return true;
        }
        // ensure the 'sid' parameter corresponds to the 'sid' stored in the authentication ticket.
        if (!string.equals(sid, message.sid, stringcomparison.ordinal))
        {
            logger.remotesignoutsessionidinvalid();
            return true;
        }
    }

    var iss = principal?.findfirst(jwtregisteredclaimnames.iss)?.value;
    if (!string.isnullorempty(iss))
    {
        // ensure a 'iss' parameter was sent by the identity provider.
        if (string.isnullorempty(message.iss))
        {
            logger.remotesignoutissuermissing();
            return true;
        }
        // ensure the 'iss' parameter corresponds to the 'iss' stored in the authentication ticket.
        if (!string.equals(iss, message.iss, stringcomparison.ordinal))
        {
            logger.remotesignoutissuerinvalid();
            return true;
        }
    }

    logger.remotesignout();

    // we've received a remote sign-out request
    await context.signoutasync(options.signoutscheme);
    return true;
}

处理本地登出 - context.signoutasync(options.signoutscheme)

方法的注释:将用户重定向到身份认证站点登出。

  • forwardxxx是所有认证配置项的基类,可以拦截使用自己配置的scheme。
  • 构造要发送给oidc服务的报文,包括issueraddress(endsessionendpoint:即结束会话节点地址),postlogoutredirecturi(登出回跳地址)等。
  • 构造redirecturi(登录流程结束最终回到的地址):优先使用httpcontext.properties中的redirecturi,然后使用配置中的signedoutredirecturi,最后使用请求源地址。
  • 获取idtoken,放到登出请求中
  • state字段加密后(包含了redirecturi等信息),放入请求消息
  • 给oidc站点发送get或者formpost请求
/// <summary>
/// redirect user to the identity provider for sign out
/// </summary>
/// <returns>a task executing the sign out procedure</returns>
public async virtual task signoutasync(authenticationproperties properties)
{
    var target = resolvetarget(options.forwardsignout);
    if (target != null)
    {
        await context.signoutasync(target, properties);
        return;
    }

    properties = properties ?? new authenticationproperties();

    logger.enteringopenidauthenticationhandlerhandlesignoutasync(gettype().fullname);

    if (_configuration == null && options.configurationmanager != null)
    {
        _configuration = await options.configurationmanager.getconfigurationasync(context.requestaborted);
    }

    var message = new openidconnectmessage()
    {
        enabletelemetryparameters = !options.disabletelemetry,
        issueraddress = _configuration?.endsessionendpoint ?? string.empty,

        // redirect back to signeoutcallbackpath first before user agent is redirected to actual post logout redirect uri
        postlogoutredirecturi = buildredirecturiifrelative(options.signedoutcallbackpath)
    };

    // get the post redirect uri.
    if (string.isnullorempty(properties.redirecturi))
    {
        properties.redirecturi = buildredirecturiifrelative(options.signedoutredirecturi);
        if (string.isnullorwhitespace(properties.redirecturi))
        {
            properties.redirecturi = originalpathbase + originalpath + request.querystring;
        }
    }
    logger.postsignoutredirect(properties.redirecturi);

    // attach the identity token to the logout request when possible.
    message.idtokenhint = await context.gettokenasync(options.signoutscheme, openidconnectparameternames.idtoken);

    var redirectcontext = new redirectcontext(context, scheme, options, properties)
    {
        protocolmessage = message
    };

    await events.redirecttoidentityproviderforsignout(redirectcontext);
    if (redirectcontext.handled)
    {
        logger.redirecttoidentityproviderforsignouthandledresponse();
        return;
    }

    message = redirectcontext.protocolmessage;

    if (!string.isnullorempty(message.state))
    {
        properties.items[openidconnectdefaults.userstatepropertieskey] = message.state;
    }

    message.state = options.statedataformat.protect(properties);

    if (string.isnullorempty(message.issueraddress))
    {
        throw new invalidoperationexception("cannot redirect to the end session endpoint, the configuration may be missing or invalid.");
    }

    if (options.authenticationmethod == openidconnectredirectbehavior.redirectget)
    {
        var redirecturi = message.createlogoutrequesturl();
        if (!uri.iswellformeduristring(redirecturi, urikind.absolute))
        {
            logger.invalidlogoutquerystringredirecturl(redirecturi);
        }

        response.redirect(redirecturi);
    }
    else if (options.authenticationmethod == openidconnectredirectbehavior.formpost)
    {
        var content = message.buildformpost();
        var buffer = encoding.utf8.getbytes(content);

        response.contentlength = buffer.length;
        response.contenttype = "text/html;charset=utf-8";

        // emit cache-control=no-cache to prevent client caching.
        response.headers[headernames.cachecontrol] = "no-cache, no-store";
        response.headers[headernames.pragma] = "no-cache";
        response.headers[headernames.expires] = headervalueepocdate;

        await response.body.writeasync(buffer, 0, buffer.length);
    }
    else
    {
        throw new notimplementedexception($"an unsupported authentication method has been configured: {options.authenticationmethod}");
    }

    logger.authenticationschemesignedout(scheme.name);
}

oidc处理完后跳到回调地址

oidc站点处理完登出请求之后(怎么处理的,应该是清除了oidc的cookie,或许回收了token?目前不清楚。后面看identitserver怎么实现的),回跳到callback地址,执行下面的callback方法

callback方法很简单,就是将state字段解码,将redirect_uri拿到,然后跳过去。

/// <summary>
/// response to the callback from openid provider after session ended.
/// </summary>
/// <returns>a task executing the callback procedure</returns>
protected async virtual task<bool> handlesignoutcallbackasync()
{
    var message = new openidconnectmessage(request.query.select(pair => new keyvaluepair<string, string[]>(pair.key, pair.value)));
    authenticationproperties properties = null;
    if (!string.isnullorempty(message.state))
    {
        properties = options.statedataformat.unprotect(message.state);
    }

    var signout = new remotesignoutcontext(context, scheme, options, message)
    {
        properties = properties,
    };

    await events.signedoutcallbackredirect(signout);
    if (signout.result != null)
    {
        if (signout.result.handled)
        {
            logger.signoutcallbackredirecthandledresponse();
            return true;
        }
        if (signout.result.skipped)
        {
            logger.signoutcallbackredirectskipped();
            return false;
        }
        if (signout.result.failure != null)
        {
            throw new invalidoperationexception("an error was returned from the signedoutcallbackredirect event.", signout.result.failure);
        }
    }

    properties = signout.properties;
    if (!string.isnullorempty(properties?.redirecturi))
    {
        response.redirect(properties.redirecturi);
    }

    return true;
}

登出时序图

sequencediagram mysite->>sso: get/formpost mysite/connect/endsession?params... sso->>mysite: 302,移除sso站点cookie,回调到signout-callback地址 mysite->>mysite: 从state中解析redirect_uri,回跳redirect_uri

可以看到,oidc的登出只处理了oidc认证站点的cookie,mysite本地的cookie是没有处理的,因为当前schema是openidconnnect,本地cookie是signinschema的事情,所以登出需要掉两次signout方法

httpcontext.signoutasync("cookies"); //清除本地cookie
httpcontext.signoutasync("openidconnect") //清除远程sso站点cookie

处理质询 - handlechallengeasync

  • oauth&pkce的处理,pkce = proof key for code exchange。主要用于nativeapp防跨站攻击的,因为nativeapp没有cookie支持,无法使用state字段,所以需要其他的安全保障。

  • 拼装请求参数,根据配置,如果是get,302跳转到oidc站点;如果是form-post,提交表单到oidc站点。
/// <summary>
/// responds to a 401 challenge. sends an openidconnect message to the 'identity authority' to obtain an identity.
/// </summary>
/// <returns></returns>
protected override async task handlechallengeasync(authenticationproperties properties)
{
    await handlechallengeasyncinternal(properties);
    var location = context.response.headers[headernames.location];
    if (location == stringvalues.empty)
    {
        location = "(not set)";
    }
    var cookie = context.response.headers[headernames.setcookie];
    if (cookie == stringvalues.empty)
    {
        cookie = "(not set)";
    }
    logger.handlechallenge(location, cookie);
}

private async task handlechallengeasyncinternal(authenticationproperties properties)
{
    logger.enteringopenidauthenticationhandlerhandleunauthorizedasync(gettype().fullname);

    // order for local redirecturi
    // 1. challenge.properties.redirecturi
    // 2. currenturi if redirecturi is not set)
    if (string.isnullorempty(properties.redirecturi))
    {
        properties.redirecturi = originalpathbase + originalpath + request.querystring;
    }
    logger.postauthenticationlocalredirect(properties.redirecturi);

    if (_configuration == null && options.configurationmanager != null)
    {
        _configuration = await options.configurationmanager.getconfigurationasync(context.requestaborted);
    }

    var message = new openidconnectmessage
    {
        clientid = options.clientid,
        enabletelemetryparameters = !options.disabletelemetry,
        issueraddress = _configuration?.authorizationendpoint ?? string.empty,
        redirecturi = buildredirecturi(options.callbackpath),
        resource = options.resource,
        responsetype = options.responsetype,
        prompt = properties.getparameter<string>(openidconnectparameternames.prompt) ?? options.prompt,
        scope = string.join(" ", properties.getparameter<icollection<string>>(openidconnectparameternames.scope) ?? options.scope),
    };

    // https://tools.ietf.org/html/rfc7636
    if (options.usepkce && options.responsetype == openidconnectresponsetype.code)
    {
        var bytes = new byte[32];
        cryptorandom.getbytes(bytes);
        var codeverifier = base64urltextencoder.encode(bytes);

        // store this for use during the code redemption. see runauthorizationcodereceivedeventasync.
        properties.items.add(oauthconstants.codeverifierkey, codeverifier);

        using var sha256 = sha256.create();
        var challengebytes = sha256.computehash(encoding.utf8.getbytes(codeverifier));
        var codechallenge = webencoders.base64urlencode(challengebytes);

        message.parameters.add(oauthconstants.codechallengekey, codechallenge);
        message.parameters.add(oauthconstants.codechallengemethodkey, oauthconstants.codechallengemethods256);
    }

    // add the 'max_age' parameter to the authentication request if maxage is not null.
    // see http://openid.net/specs/openid-connect-core-1_0.html#authrequest
    var maxage = properties.getparameter<timespan?>(openidconnectparameternames.maxage) ?? options.maxage;
    if (maxage.hasvalue)
    {
        message.maxage = convert.toint64(math.floor((maxage.value).totalseconds))
            .tostring(cultureinfo.invariantculture);
    }

    // omitting the response_mode parameter when it already corresponds to the default
    // response_mode used for the specified response_type is recommended by the specifications.
    // see http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#responsemodes
    if (!string.equals(options.responsetype, openidconnectresponsetype.code, stringcomparison.ordinal) ||
        !string.equals(options.responsemode, openidconnectresponsemode.query, stringcomparison.ordinal))
    {
        message.responsemode = options.responsemode;
    }

    if (options.protocolvalidator.requirenonce)
    {
        message.nonce = options.protocolvalidator.generatenonce();
        writenoncecookie(message.nonce);
    }

    generatecorrelationid(properties);

    var redirectcontext = new redirectcontext(context, scheme, options, properties)
    {
        protocolmessage = message
    };

    await events.redirecttoidentityprovider(redirectcontext);
    if (redirectcontext.handled)
    {
        logger.redirecttoidentityproviderhandledresponse();
        return;
    }

    message = redirectcontext.protocolmessage;

    if (!string.isnullorempty(message.state))
    {
        properties.items[openidconnectdefaults.userstatepropertieskey] = message.state;
    }

    // when redeeming a 'code' for an accesstoken, this value is needed
    properties.items.add(openidconnectdefaults.redirecturiforcodepropertieskey, message.redirecturi);

    message.state = options.statedataformat.protect(properties);

    if (string.isnullorempty(message.issueraddress))
    {
        throw new invalidoperationexception(
            "cannot redirect to the authorization endpoint, the configuration may be missing or invalid.");
    }

    if (options.authenticationmethod == openidconnectredirectbehavior.redirectget)
    {
        var redirecturi = message.createauthenticationrequesturl();
        if (!uri.iswellformeduristring(redirecturi, urikind.absolute))
        {
            logger.invalidauthenticationrequesturl(redirecturi);
        }

        response.redirect(redirecturi);
        return;
    }
    else if (options.authenticationmethod == openidconnectredirectbehavior.formpost)
    {
        var content = message.buildformpost();
        var buffer = encoding.utf8.getbytes(content);

        response.contentlength = buffer.length;
        response.contenttype = "text/html;charset=utf-8";

        // emit cache-control=no-cache to prevent client caching.
        response.headers[headernames.cachecontrol] = "no-cache, no-store";
        response.headers[headernames.pragma] = "no-cache";
        response.headers[headernames.expires] = headervalueepocdate;

        await response.body.writeasync(buffer, 0, buffer.length);
        return;
    }

    throw new notimplementedexception($"an unsupported authentication method has been configured: {options.authenticationmethod}");
}

openidconnect的代码还是有点复杂的,很多细节无法覆盖到,后面学习了协议再回头梳理一下。

如对本文有疑问,请在下面进行留言讨论,广大热心网友会与你互动!! 点击进行留言回复

相关文章:

验证码:
移动技术网