欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

AspNetCore3.1_Secutiry源码解析_6_Authentication_OpenIdConnect

程序员文章站 2022-04-14 16:14:02
目录 "AspNetCore3.1_Secutiry源码解析_1_目录" "AspNetCore3.1_Secutiry源码解析_2_Authentication_核心流程" "AspNetCore3.1_Secutiry源码解析_3_Authentication_Cookies" "AspNetC ......

目录

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的代码还是有点复杂的,很多细节无法覆盖到,后面学习了协议再回头梳理一下。