文章目錄
oidc簡介
oidc是基于oauth2.0的上層協(xié)議。
OAuth有點像賣電影票的,只關心用戶能不能進電影院,不關心用戶是誰。而oidc則像身份證,掃描就可以上飛機,一次掃描,機場不僅能知道你是否能上飛機,還可以知道你的身份信息。
oidc兼容OAuth2.0, 可以實現跨頂級域的SSO(單點登錄、登出),下個系列要學習的IdentityServer4就是對oidc協(xié)議族的一個具體實現框架。
更多理論知識看下面的參考資料,本系列主要過下源碼脈絡
博客園
https://www.cnblogs.com/linianhui/p/openid-connect-core.html
協(xié)議
https:///connect/
依賴注入
默認架構名稱是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:///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沒有別的方式可以維持登錄態(tài)?)
校驗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的認證節(jié)點地址/connect/authorize發(fā)送請求,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的處理
代碼量還是比較多,有些地方目前還不是特別理解,需求后面熟悉協(xié)議內容在回過頭來看下??傮w上就是對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:///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./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:///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兩種提交方式,客戶端根據請求方式,將報文拼裝好。
- 觸發(fā)遠程登出事件
- 使用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。
- 構造要發(fā)送給oidc服務的報文,包括IssuerAddress(EndSessionEndpoint:即結束會話節(jié)點地址),PostLogoutRedirectUri(登出回跳地址)等。
- 構造RedirectUri(登錄流程結束最終回到的地址):優(yōu)先使用HttpContext.Properties中的RedirectUri,然后使用配置中的SignedOutRedirectUri,最后使用請求源地址。
- 獲取IdToken,放到登出請求中
- state字段加密后(包含了redirecturi等信息),放入請求消息
- 給oidc站點發(fā)送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地址,執(zhí)行下面的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字段,所以需要其他的安全保障。
https://tools./html/rfc7636
- 拼裝請求參數,根據配置,如果是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./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:///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:///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的代碼還是有點復雜的,很多細節(jié)無法覆蓋到,后面學習了協(xié)議再回頭梳理一下。
|