目录

  • aspnetcore3.1_secutiry源码解析_1_目录
  • aspnetcore3.1_secutiry源码解析_2_authentication_核心流程
  • aspnetcore3.1_secutiry源码解析_3_authentication_cookies
  • aspnetcore3.1_secutiry源码解析_4_authentication_jwtbear
  • aspnetcore3.1_secutiry源码解析_5_authentication_oauth
  • aspnetcore3.1_secutiry源码解析_6_authentication_openidconnect
  • aspnetcore3.1_secutiry源码解析_7_authentication_其他
  • aspnetcore3.1_secutiry源码解析_8_authorization_授权框架

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