parent
dda84bc7fc
commit
6e25a9fc53
|
|
@ -20,7 +20,17 @@ namespace NegotiateAuthSample
|
||||||
options.FallbackPolicy = options.DefaultPolicy;
|
options.FallbackPolicy = options.DefaultPolicy;
|
||||||
});
|
});
|
||||||
services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
|
services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
|
||||||
.AddNegotiate();
|
.AddNegotiate(options =>
|
||||||
|
{
|
||||||
|
options.Events = new NegotiateEvents()
|
||||||
|
{
|
||||||
|
OnAuthenticationFailed = context =>
|
||||||
|
{
|
||||||
|
// context.SkipHandler();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
|
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
// For testing
|
// For testing
|
||||||
internal interface INegotiateState : IDisposable
|
internal interface INegotiateState : IDisposable
|
||||||
{
|
{
|
||||||
string GetOutgoingBlob(string incomingBlob);
|
string GetOutgoingBlob(string incomingBlob, out BlobErrorType status, out Exception error);
|
||||||
|
|
||||||
bool IsCompleted { get; }
|
bool IsCompleted { get; }
|
||||||
|
|
||||||
|
|
@ -17,4 +17,12 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
|
|
||||||
IIdentity GetIdentity();
|
IIdentity GetIdentity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal enum BlobErrorType
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
CredentialError,
|
||||||
|
ClientError,
|
||||||
|
Other
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ namespace Microsoft.Extensions.Logging
|
||||||
private static Action<ILogger, Exception> _enablingCredentialPersistence;
|
private static Action<ILogger, Exception> _enablingCredentialPersistence;
|
||||||
private static Action<ILogger, string, Exception> _disablingCredentialPersistence;
|
private static Action<ILogger, string, Exception> _disablingCredentialPersistence;
|
||||||
private static Action<ILogger, Exception> _exceptionProcessingAuth;
|
private static Action<ILogger, Exception> _exceptionProcessingAuth;
|
||||||
|
private static Action<ILogger, Exception> _credentialError;
|
||||||
|
private static Action<ILogger, Exception> _clientError;
|
||||||
private static Action<ILogger, Exception> _challengeNegotiate;
|
private static Action<ILogger, Exception> _challengeNegotiate;
|
||||||
private static Action<ILogger, Exception> _reauthenticating;
|
private static Action<ILogger, Exception> _reauthenticating;
|
||||||
private static Action<ILogger, Exception> _deferring;
|
private static Action<ILogger, Exception> _deferring;
|
||||||
|
|
@ -50,6 +52,14 @@ namespace Microsoft.Extensions.Logging
|
||||||
eventId: new EventId(8, "Deferring"),
|
eventId: new EventId(8, "Deferring"),
|
||||||
logLevel: LogLevel.Information,
|
logLevel: LogLevel.Information,
|
||||||
formatString: "Deferring to the server's implementation of Windows Authentication.");
|
formatString: "Deferring to the server's implementation of Windows Authentication.");
|
||||||
|
_credentialError = LoggerMessage.Define(
|
||||||
|
eventId: new EventId(9, "CredentialError"),
|
||||||
|
logLevel: LogLevel.Debug,
|
||||||
|
formatString: "There was a problem with the users credentials.");
|
||||||
|
_clientError = LoggerMessage.Define(
|
||||||
|
eventId: new EventId(10, "ClientError"),
|
||||||
|
logLevel: LogLevel.Debug,
|
||||||
|
formatString: "The users authentication request was invalid.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void IncompleteNegotiateChallenge(this ILogger logger)
|
public static void IncompleteNegotiateChallenge(this ILogger logger)
|
||||||
|
|
@ -75,5 +85,11 @@ namespace Microsoft.Extensions.Logging
|
||||||
|
|
||||||
public static void Deferring(this ILogger logger)
|
public static void Deferring(this ILogger logger)
|
||||||
=> _deferring(logger, null);
|
=> _deferring(logger, null);
|
||||||
|
|
||||||
|
public static void CredentialError(this ILogger logger, Exception ex)
|
||||||
|
=> _credentialError(logger, ex);
|
||||||
|
|
||||||
|
public static void ClientError(this ILogger logger, Exception ex)
|
||||||
|
=> _clientError(logger, ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Security.Authentication;
|
using System.Security.Authentication;
|
||||||
using System.Security.Principal;
|
using System.Security.Principal;
|
||||||
|
|
||||||
|
|
@ -12,21 +14,30 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
{
|
{
|
||||||
internal class ReflectedNegotiateState : INegotiateState
|
internal class ReflectedNegotiateState : INegotiateState
|
||||||
{
|
{
|
||||||
|
// https://www.gnu.org/software/gss/reference/gss.pdf
|
||||||
|
private const uint GSS_S_NO_CRED = 7 << 16;
|
||||||
|
|
||||||
private static readonly ConstructorInfo _constructor;
|
private static readonly ConstructorInfo _constructor;
|
||||||
private static readonly MethodInfo _getOutgoingBlob;
|
private static readonly MethodInfo _getOutgoingBlob;
|
||||||
private static readonly MethodInfo _isCompleted;
|
private static readonly MethodInfo _isCompleted;
|
||||||
private static readonly MethodInfo _protocol;
|
private static readonly MethodInfo _protocol;
|
||||||
private static readonly MethodInfo _getIdentity;
|
private static readonly MethodInfo _getIdentity;
|
||||||
private static readonly MethodInfo _closeContext;
|
private static readonly MethodInfo _closeContext;
|
||||||
|
private static readonly FieldInfo _statusCode;
|
||||||
|
private static readonly FieldInfo _statusException;
|
||||||
|
private static readonly MethodInfo _getException;
|
||||||
|
private static readonly FieldInfo _gssMinorStatus;
|
||||||
|
private static readonly Type _gssExceptionType;
|
||||||
|
|
||||||
private readonly object _instance;
|
private readonly object _instance;
|
||||||
|
|
||||||
static ReflectedNegotiateState()
|
static ReflectedNegotiateState()
|
||||||
{
|
{
|
||||||
var ntAuthType = typeof(AuthenticationException).Assembly.GetType("System.Net.NTAuthentication");
|
var secAssembly = typeof(AuthenticationException).Assembly;
|
||||||
|
var ntAuthType = secAssembly.GetType("System.Net.NTAuthentication", throwOnError: true);
|
||||||
_constructor = ntAuthType.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance).First();
|
_constructor = ntAuthType.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance).First();
|
||||||
_getOutgoingBlob = ntAuthType.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).Where(info =>
|
_getOutgoingBlob = ntAuthType.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).Where(info =>
|
||||||
info.Name.Equals("GetOutgoingBlob") && info.GetParameters().Count() == 2).Single();
|
info.Name.Equals("GetOutgoingBlob") && info.GetParameters().Count() == 3).Single();
|
||||||
_isCompleted = ntAuthType.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).Where(info =>
|
_isCompleted = ntAuthType.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).Where(info =>
|
||||||
info.Name.Equals("get_IsCompleted")).Single();
|
info.Name.Equals("get_IsCompleted")).Single();
|
||||||
_protocol = ntAuthType.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).Where(info =>
|
_protocol = ntAuthType.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).Where(info =>
|
||||||
|
|
@ -34,9 +45,23 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
_closeContext = ntAuthType.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).Where(info =>
|
_closeContext = ntAuthType.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).Where(info =>
|
||||||
info.Name.Equals("CloseContext")).Single();
|
info.Name.Equals("CloseContext")).Single();
|
||||||
|
|
||||||
var negoStreamPalType = typeof(AuthenticationException).Assembly.GetType("System.Net.Security.NegotiateStreamPal");
|
var securityStatusType = secAssembly.GetType("System.Net.SecurityStatusPal", throwOnError: true);
|
||||||
|
_statusCode = securityStatusType.GetField("ErrorCode");
|
||||||
|
_statusException = securityStatusType.GetField("Exception");
|
||||||
|
|
||||||
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
{
|
||||||
|
var interopType = secAssembly.GetType("Interop", throwOnError: true);
|
||||||
|
var netNativeType = interopType.GetNestedType("NetSecurityNative", BindingFlags.NonPublic | BindingFlags.Static);
|
||||||
|
_gssExceptionType = netNativeType.GetNestedType("GssApiException", BindingFlags.NonPublic);
|
||||||
|
_gssMinorStatus = _gssExceptionType.GetField("_minorStatus", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
var negoStreamPalType = secAssembly.GetType("System.Net.Security.NegotiateStreamPal", throwOnError: true);
|
||||||
_getIdentity = negoStreamPalType.GetMethods(BindingFlags.NonPublic | BindingFlags.Static).Where(info =>
|
_getIdentity = negoStreamPalType.GetMethods(BindingFlags.NonPublic | BindingFlags.Static).Where(info =>
|
||||||
info.Name.Equals("GetIdentity")).Single();
|
info.Name.Equals("GetIdentity")).Single();
|
||||||
|
_getException = negoStreamPalType.GetMethods(BindingFlags.NonPublic | BindingFlags.Static).Where(info =>
|
||||||
|
info.Name.Equals("CreateExceptionFromError")).Single();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReflectedNegotiateState()
|
public ReflectedNegotiateState()
|
||||||
|
|
@ -50,14 +75,15 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
// The client doesn't need the context once auth is complete, but the server does.
|
// The client doesn't need the context once auth is complete, but the server does.
|
||||||
// I'm not sure why it auto-closes for the client given that the client closes it just a few lines later.
|
// I'm not sure why it auto-closes for the client given that the client closes it just a few lines later.
|
||||||
// https://github.com/dotnet/corefx/blob/a3ab91e10045bb298f48c1d1f9bd5b0782a8ac46/src/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.NtAuth.cs#L134
|
// https://github.com/dotnet/corefx/blob/a3ab91e10045bb298f48c1d1f9bd5b0782a8ac46/src/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.NtAuth.cs#L134
|
||||||
public string GetOutgoingBlob(string incomingBlob)
|
public string GetOutgoingBlob(string incomingBlob, out BlobErrorType status, out Exception error)
|
||||||
{
|
{
|
||||||
byte[] decodedIncomingBlob = null;
|
byte[] decodedIncomingBlob = null;
|
||||||
if (incomingBlob != null && incomingBlob.Length > 0)
|
if (incomingBlob != null && incomingBlob.Length > 0)
|
||||||
{
|
{
|
||||||
decodedIncomingBlob = Convert.FromBase64String(incomingBlob);
|
decodedIncomingBlob = Convert.FromBase64String(incomingBlob);
|
||||||
}
|
}
|
||||||
byte[] decodedOutgoingBlob = GetOutgoingBlob(decodedIncomingBlob, true);
|
|
||||||
|
byte[] decodedOutgoingBlob = GetOutgoingBlob(decodedIncomingBlob, out status, out error);
|
||||||
|
|
||||||
string outgoingBlob = null;
|
string outgoingBlob = null;
|
||||||
if (decodedOutgoingBlob != null && decodedOutgoingBlob.Length > 0)
|
if (decodedOutgoingBlob != null && decodedOutgoingBlob.Length > 0)
|
||||||
|
|
@ -68,9 +94,65 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
return outgoingBlob;
|
return outgoingBlob;
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] GetOutgoingBlob(byte[] incomingBlob, bool thrownOnError)
|
private byte[] GetOutgoingBlob(byte[] incomingBlob, out BlobErrorType status, out Exception error)
|
||||||
{
|
{
|
||||||
return (byte[])_getOutgoingBlob.Invoke(_instance, new object[] { incomingBlob, thrownOnError });
|
try
|
||||||
|
{
|
||||||
|
// byte[] GetOutgoingBlob(byte[] incomingBlob, bool throwOnError, out SecurityStatusPal statusCode)
|
||||||
|
var parameters = new object[] { incomingBlob, false, null };
|
||||||
|
var blob = (byte[])_getOutgoingBlob.Invoke(_instance, parameters);
|
||||||
|
|
||||||
|
var securityStatus = parameters[2];
|
||||||
|
// TODO: Update after corefx changes
|
||||||
|
error = (Exception)(_statusException.GetValue(securityStatus)
|
||||||
|
?? _getException.Invoke(null, new[] { securityStatus }));
|
||||||
|
var errorCode = (SecurityStatusPalErrorCode)_statusCode.GetValue(securityStatus);
|
||||||
|
|
||||||
|
// TODO: Remove after corefx changes
|
||||||
|
// The linux implementation always uses InternalError;
|
||||||
|
if (errorCode == SecurityStatusPalErrorCode.InternalError
|
||||||
|
&& !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||||
|
&& _gssExceptionType.IsInstanceOfType(error))
|
||||||
|
{
|
||||||
|
var majorStatus = (uint)error.HResult;
|
||||||
|
var minorStatus = (uint)_gssMinorStatus.GetValue(error);
|
||||||
|
|
||||||
|
// Remap specific errors
|
||||||
|
if (majorStatus == GSS_S_NO_CRED && minorStatus == 0)
|
||||||
|
{
|
||||||
|
errorCode = SecurityStatusPalErrorCode.UnknownCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
error = new Exception($"An authentication exception occured (0x{majorStatus:X}/0x{minorStatus:X}).", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorCode == SecurityStatusPalErrorCode.OK
|
||||||
|
|| errorCode == SecurityStatusPalErrorCode.ContinueNeeded
|
||||||
|
|| errorCode == SecurityStatusPalErrorCode.CompleteNeeded)
|
||||||
|
{
|
||||||
|
status = BlobErrorType.None;
|
||||||
|
}
|
||||||
|
else if (IsCredentialError(errorCode))
|
||||||
|
{
|
||||||
|
status = BlobErrorType.CredentialError;
|
||||||
|
}
|
||||||
|
else if (IsClientError(errorCode))
|
||||||
|
{
|
||||||
|
status = BlobErrorType.ClientError;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
status = BlobErrorType.Other;
|
||||||
|
}
|
||||||
|
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
catch (TargetInvocationException tex)
|
||||||
|
{
|
||||||
|
// Unwrap
|
||||||
|
ExceptionDispatchInfo.Capture(tex.InnerException).Throw();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsCompleted
|
public bool IsCompleted
|
||||||
|
|
@ -92,5 +174,36 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
{
|
{
|
||||||
_closeContext.Invoke(_instance, Array.Empty<object>());
|
_closeContext.Invoke(_instance, Array.Empty<object>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsCredentialError(SecurityStatusPalErrorCode error)
|
||||||
|
{
|
||||||
|
return error == SecurityStatusPalErrorCode.LogonDenied ||
|
||||||
|
error == SecurityStatusPalErrorCode.UnknownCredentials ||
|
||||||
|
error == SecurityStatusPalErrorCode.NoImpersonation ||
|
||||||
|
error == SecurityStatusPalErrorCode.NoAuthenticatingAuthority ||
|
||||||
|
error == SecurityStatusPalErrorCode.UntrustedRoot ||
|
||||||
|
error == SecurityStatusPalErrorCode.CertExpired ||
|
||||||
|
error == SecurityStatusPalErrorCode.SmartcardLogonRequired ||
|
||||||
|
error == SecurityStatusPalErrorCode.BadBinding;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsClientError(SecurityStatusPalErrorCode error)
|
||||||
|
{
|
||||||
|
return error == SecurityStatusPalErrorCode.InvalidToken ||
|
||||||
|
error == SecurityStatusPalErrorCode.CannotPack ||
|
||||||
|
error == SecurityStatusPalErrorCode.QopNotSupported ||
|
||||||
|
error == SecurityStatusPalErrorCode.NoCredentials ||
|
||||||
|
error == SecurityStatusPalErrorCode.MessageAltered ||
|
||||||
|
error == SecurityStatusPalErrorCode.OutOfSequence ||
|
||||||
|
error == SecurityStatusPalErrorCode.IncompleteMessage ||
|
||||||
|
error == SecurityStatusPalErrorCode.IncompleteCredentials ||
|
||||||
|
error == SecurityStatusPalErrorCode.WrongPrincipal ||
|
||||||
|
error == SecurityStatusPalErrorCode.TimeSkew ||
|
||||||
|
error == SecurityStatusPalErrorCode.IllegalMessage ||
|
||||||
|
error == SecurityStatusPalErrorCode.CertUnknown ||
|
||||||
|
error == SecurityStatusPalErrorCode.AlgorithmMismatch ||
|
||||||
|
error == SecurityStatusPalErrorCode.SecurityQosFailed ||
|
||||||
|
error == SecurityStatusPalErrorCode.UnsupportedPreauth;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
|
{
|
||||||
|
internal enum SecurityStatusPalErrorCode
|
||||||
|
{
|
||||||
|
NotSet = 0,
|
||||||
|
OK,
|
||||||
|
ContinueNeeded,
|
||||||
|
CompleteNeeded,
|
||||||
|
CompAndContinue,
|
||||||
|
ContextExpired,
|
||||||
|
CredentialsNeeded,
|
||||||
|
Renegotiate,
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
OutOfMemory,
|
||||||
|
InvalidHandle,
|
||||||
|
Unsupported,
|
||||||
|
TargetUnknown,
|
||||||
|
InternalError,
|
||||||
|
PackageNotFound,
|
||||||
|
NotOwner,
|
||||||
|
CannotInstall,
|
||||||
|
InvalidToken,
|
||||||
|
CannotPack,
|
||||||
|
QopNotSupported,
|
||||||
|
NoImpersonation,
|
||||||
|
LogonDenied,
|
||||||
|
UnknownCredentials,
|
||||||
|
NoCredentials,
|
||||||
|
MessageAltered,
|
||||||
|
OutOfSequence,
|
||||||
|
NoAuthenticatingAuthority,
|
||||||
|
IncompleteMessage,
|
||||||
|
IncompleteCredentials,
|
||||||
|
BufferNotEnough,
|
||||||
|
WrongPrincipal,
|
||||||
|
TimeSkew,
|
||||||
|
UntrustedRoot,
|
||||||
|
IllegalMessage,
|
||||||
|
CertUnknown,
|
||||||
|
CertExpired,
|
||||||
|
DecryptFailure,
|
||||||
|
AlgorithmMismatch,
|
||||||
|
SecurityQosFailed,
|
||||||
|
SmartcardLogonRequired,
|
||||||
|
UnsupportedPreauth,
|
||||||
|
BadBinding,
|
||||||
|
DowngradeDetected,
|
||||||
|
ApplicationProtocolMismatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,8 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
/// <returns>True if a response was generated, false otherwise.</returns>
|
/// <returns>True if a response was generated, false otherwise.</returns>
|
||||||
public async Task<bool> HandleRequestAsync()
|
public async Task<bool> HandleRequestAsync()
|
||||||
{
|
{
|
||||||
|
AuthPersistence persistence = null;
|
||||||
|
bool authFailedEventCalled = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_requestProcessed || Options.DeferToServer)
|
if (_requestProcessed || Options.DeferToServer)
|
||||||
|
|
@ -86,7 +88,7 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
}
|
}
|
||||||
|
|
||||||
var connectionItems = GetConnectionItems();
|
var connectionItems = GetConnectionItems();
|
||||||
var persistence = (AuthPersistence)connectionItems[AuthPersistenceKey];
|
persistence = (AuthPersistence)connectionItems[AuthPersistenceKey];
|
||||||
_negotiateState = persistence?.State;
|
_negotiateState = persistence?.State;
|
||||||
|
|
||||||
var authorizationHeader = Request.Headers[HeaderNames.Authorization];
|
var authorizationHeader = Request.Headers[HeaderNames.Authorization];
|
||||||
|
|
@ -126,7 +128,40 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
|
|
||||||
_negotiateState ??= Options.StateFactory.CreateInstance();
|
_negotiateState ??= Options.StateFactory.CreateInstance();
|
||||||
|
|
||||||
var outgoing = _negotiateState.GetOutgoingBlob(token);
|
var outgoing = _negotiateState.GetOutgoingBlob(token, out var errorType, out var exception);
|
||||||
|
Logger.LogInformation(errorType.ToString());
|
||||||
|
if (errorType != BlobErrorType.None)
|
||||||
|
{
|
||||||
|
_negotiateState.Dispose();
|
||||||
|
_negotiateState = null;
|
||||||
|
if (persistence?.State != null)
|
||||||
|
{
|
||||||
|
persistence.State.Dispose();
|
||||||
|
persistence.State = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorType == BlobErrorType.CredentialError)
|
||||||
|
{
|
||||||
|
Logger.CredentialError(exception);
|
||||||
|
authFailedEventCalled = true; // Could throw, and we don't want to double trigger the event.
|
||||||
|
var result = await InvokeAuthenticateFailedEvent(exception);
|
||||||
|
return result ?? false; // Default to skipping the handler, let AuthZ generate a new 401
|
||||||
|
}
|
||||||
|
else if (errorType == BlobErrorType.ClientError)
|
||||||
|
{
|
||||||
|
Logger.ClientError(exception);
|
||||||
|
authFailedEventCalled = true; // Could throw, and we don't want to double trigger the event.
|
||||||
|
var result = await InvokeAuthenticateFailedEvent(exception);
|
||||||
|
if (result.HasValue)
|
||||||
|
{
|
||||||
|
return result.Value;
|
||||||
|
}
|
||||||
|
Context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||||
|
return true; // Default to terminating request
|
||||||
|
}
|
||||||
|
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
|
||||||
if (!_negotiateState.IsCompleted)
|
if (!_negotiateState.IsCompleted)
|
||||||
{
|
{
|
||||||
|
|
@ -193,24 +228,26 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.ExceptionProcessingAuth(ex);
|
if (authFailedEventCalled)
|
||||||
var errorContext = new AuthenticationFailedContext(Context, Scheme, Options) { Exception = ex };
|
|
||||||
await Events.AuthenticationFailed(errorContext);
|
|
||||||
|
|
||||||
if (errorContext.Result != null)
|
|
||||||
{
|
{
|
||||||
if (errorContext.Result.Handled)
|
throw;
|
||||||
{
|
}
|
||||||
return true;
|
|
||||||
}
|
Logger.ExceptionProcessingAuth(ex);
|
||||||
else if (errorContext.Result.Skipped)
|
|
||||||
{
|
// Clear state so it's possible to retry on the same connection.
|
||||||
return false;
|
_negotiateState?.Dispose();
|
||||||
}
|
_negotiateState = null;
|
||||||
else if (errorContext.Result.Failure != null)
|
if (persistence?.State != null)
|
||||||
{
|
{
|
||||||
throw new Exception("An error was returned from the AuthenticationFailed event.", errorContext.Result.Failure);
|
persistence.State.Dispose();
|
||||||
}
|
persistence.State = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await InvokeAuthenticateFailedEvent(ex);
|
||||||
|
if (result.HasValue)
|
||||||
|
{
|
||||||
|
return result.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
|
|
@ -219,6 +256,30 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<bool?> InvokeAuthenticateFailedEvent(Exception ex)
|
||||||
|
{
|
||||||
|
var errorContext = new AuthenticationFailedContext(Context, Scheme, Options) { Exception = ex };
|
||||||
|
await Events.AuthenticationFailed(errorContext);
|
||||||
|
|
||||||
|
if (errorContext.Result != null)
|
||||||
|
{
|
||||||
|
if (errorContext.Result.Handled)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else if (errorContext.Result.Skipped)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else if (errorContext.Result.Failure != null)
|
||||||
|
{
|
||||||
|
throw new Exception("An error was returned from the AuthenticationFailed event.", errorContext.Result.Failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if the current request is authenticated and returns the user.
|
/// Checks if the current request is authenticated and returns the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Reflection.Metadata;
|
||||||
using System.Security.Principal;
|
using System.Security.Principal;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
|
@ -71,16 +72,16 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OnAuthenticationFailed_Fires()
|
public async Task OnAuthenticationFailed_FromException_Fires()
|
||||||
{
|
{
|
||||||
var eventInvoked = false;
|
var eventInvoked = 0;
|
||||||
using var host = await CreateHostAsync(options =>
|
using var host = await CreateHostAsync(options =>
|
||||||
{
|
{
|
||||||
options.Events = new NegotiateEvents()
|
options.Events = new NegotiateEvents()
|
||||||
{
|
{
|
||||||
OnAuthenticationFailed = context =>
|
OnAuthenticationFailed = context =>
|
||||||
{
|
{
|
||||||
eventInvoked = true;
|
eventInvoked++;
|
||||||
Assert.IsType<InvalidOperationException>(context.Exception);
|
Assert.IsType<InvalidOperationException>(context.Exception);
|
||||||
Assert.Equal("InvalidBlob", context.Exception.Message);
|
Assert.Equal("InvalidBlob", context.Exception.Message);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
@ -92,11 +93,11 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
SendAsync(server, "/404", new TestConnection(), "Negotiate InvalidBlob"));
|
SendAsync(server, "/404", new TestConnection(), "Negotiate InvalidBlob"));
|
||||||
Assert.Equal("InvalidBlob", ex.Message);
|
Assert.Equal("InvalidBlob", ex.Message);
|
||||||
Assert.True(eventInvoked);
|
Assert.Equal(1, eventInvoked);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OnAuthenticationFailed_Handled()
|
public async Task OnAuthenticationFailed_FromException_Handled()
|
||||||
{
|
{
|
||||||
using var host = await CreateHostAsync(options =>
|
using var host = await CreateHostAsync(options =>
|
||||||
{
|
{
|
||||||
|
|
@ -104,7 +105,7 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
{
|
{
|
||||||
OnAuthenticationFailed = context =>
|
OnAuthenticationFailed = context =>
|
||||||
{
|
{
|
||||||
context.Response.StatusCode = StatusCodes.Status418ImATeapot; ;
|
context.Response.StatusCode = StatusCodes.Status418ImATeapot;
|
||||||
context.Response.Headers[HeaderNames.WWWAuthenticate] = "Teapot";
|
context.Response.Headers[HeaderNames.WWWAuthenticate] = "Teapot";
|
||||||
context.HandleResponse();
|
context.HandleResponse();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
@ -118,6 +119,157 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
Assert.Equal("Teapot", result.Response.Headers[HeaderNames.WWWAuthenticate]);
|
Assert.Equal("Teapot", result.Response.Headers[HeaderNames.WWWAuthenticate]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnAuthenticationFailed_FromOtherBlobError_Fires()
|
||||||
|
{
|
||||||
|
var eventInvoked = 0;
|
||||||
|
using var host = await CreateHostAsync(options =>
|
||||||
|
{
|
||||||
|
options.Events = new NegotiateEvents()
|
||||||
|
{
|
||||||
|
OnAuthenticationFailed = context =>
|
||||||
|
{
|
||||||
|
eventInvoked++;
|
||||||
|
Assert.IsType<Exception>(context.Exception);
|
||||||
|
Assert.Equal("A test other error occured", context.Exception.Message);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var server = host.GetTestServer();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<Exception>(() =>
|
||||||
|
SendAsync(server, "/404", new TestConnection(), "Negotiate OtherError"));
|
||||||
|
Assert.Equal("A test other error occured", ex.Message);
|
||||||
|
Assert.Equal(1, eventInvoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnAuthenticationFailed_FromOtherBlobError_Handled()
|
||||||
|
{
|
||||||
|
var eventInvoked = 0;
|
||||||
|
using var host = await CreateHostAsync(options =>
|
||||||
|
{
|
||||||
|
options.Events = new NegotiateEvents()
|
||||||
|
{
|
||||||
|
OnAuthenticationFailed = context =>
|
||||||
|
{
|
||||||
|
eventInvoked++;
|
||||||
|
context.Response.StatusCode = StatusCodes.Status418ImATeapot;
|
||||||
|
context.Response.Headers[HeaderNames.WWWAuthenticate] = "Teapot";
|
||||||
|
context.HandleResponse();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var server = host.GetTestServer();
|
||||||
|
|
||||||
|
var result = await SendAsync(server, "/404", new TestConnection(), "Negotiate OtherError");
|
||||||
|
Assert.Equal(StatusCodes.Status418ImATeapot, result.Response.StatusCode);
|
||||||
|
Assert.Equal("Teapot", result.Response.Headers[HeaderNames.WWWAuthenticate]);
|
||||||
|
Assert.Equal(1, eventInvoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnAuthenticationFailed_FromCredentialError_Fires()
|
||||||
|
{
|
||||||
|
var eventInvoked = 0;
|
||||||
|
using var host = await CreateHostAsync(options =>
|
||||||
|
{
|
||||||
|
options.Events = new NegotiateEvents()
|
||||||
|
{
|
||||||
|
OnAuthenticationFailed = context =>
|
||||||
|
{
|
||||||
|
eventInvoked++;
|
||||||
|
Assert.IsType<Exception>(context.Exception);
|
||||||
|
Assert.Equal("A test credential error occured", context.Exception.Message);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var server = host.GetTestServer();
|
||||||
|
|
||||||
|
var response = await SendAsync(server, "/418", new TestConnection(), "Negotiate CredentialError");
|
||||||
|
Assert.Equal(StatusCodes.Status418ImATeapot, response.Response.StatusCode);
|
||||||
|
Assert.Equal(1, eventInvoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnAuthenticationFailed_FromCredentialError_Handled()
|
||||||
|
{
|
||||||
|
var eventInvoked = 0;
|
||||||
|
using var host = await CreateHostAsync(options =>
|
||||||
|
{
|
||||||
|
options.Events = new NegotiateEvents()
|
||||||
|
{
|
||||||
|
OnAuthenticationFailed = context =>
|
||||||
|
{
|
||||||
|
eventInvoked++;
|
||||||
|
context.Response.StatusCode = StatusCodes.Status418ImATeapot;
|
||||||
|
context.Response.Headers[HeaderNames.WWWAuthenticate] = "Teapot";
|
||||||
|
context.HandleResponse();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var server = host.GetTestServer();
|
||||||
|
|
||||||
|
var result = await SendAsync(server, "/404", new TestConnection(), "Negotiate CredentialError");
|
||||||
|
Assert.Equal(StatusCodes.Status418ImATeapot, result.Response.StatusCode);
|
||||||
|
Assert.Equal("Teapot", result.Response.Headers[HeaderNames.WWWAuthenticate]);
|
||||||
|
Assert.Equal(1, eventInvoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnAuthenticationFailed_FromClientError_Fires()
|
||||||
|
{
|
||||||
|
var eventInvoked = 0;
|
||||||
|
using var host = await CreateHostAsync(options =>
|
||||||
|
{
|
||||||
|
options.Events = new NegotiateEvents()
|
||||||
|
{
|
||||||
|
OnAuthenticationFailed = context =>
|
||||||
|
{
|
||||||
|
eventInvoked++;
|
||||||
|
Assert.IsType<Exception>(context.Exception);
|
||||||
|
Assert.Equal("A test client error occured", context.Exception.Message);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var server = host.GetTestServer();
|
||||||
|
|
||||||
|
var response = await SendAsync(server, "/404", new TestConnection(), "Negotiate ClientError");
|
||||||
|
Assert.Equal(StatusCodes.Status400BadRequest, response.Response.StatusCode);
|
||||||
|
Assert.Equal(1, eventInvoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnAuthenticationFailed_FromClientError_Handled()
|
||||||
|
{
|
||||||
|
var eventInvoked = 0;
|
||||||
|
using var host = await CreateHostAsync(options =>
|
||||||
|
{
|
||||||
|
options.Events = new NegotiateEvents()
|
||||||
|
{
|
||||||
|
OnAuthenticationFailed = context =>
|
||||||
|
{
|
||||||
|
eventInvoked++;
|
||||||
|
context.Response.StatusCode = StatusCodes.Status418ImATeapot;
|
||||||
|
context.Response.Headers[HeaderNames.WWWAuthenticate] = "Teapot";
|
||||||
|
context.HandleResponse();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var server = host.GetTestServer();
|
||||||
|
|
||||||
|
var result = await SendAsync(server, "/404", new TestConnection(), "Negotiate ClientError");
|
||||||
|
Assert.Equal(StatusCodes.Status418ImATeapot, result.Response.StatusCode);
|
||||||
|
Assert.Equal("Teapot", result.Response.Headers[HeaderNames.WWWAuthenticate]);
|
||||||
|
Assert.Equal(1, eventInvoked);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OnAuthenticated_FiresOncePerRequest()
|
public async Task OnAuthenticated_FiresOncePerRequest()
|
||||||
{
|
{
|
||||||
|
|
@ -278,6 +430,12 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
Assert.False(string.IsNullOrEmpty(name), "name");
|
Assert.False(string.IsNullOrEmpty(name), "name");
|
||||||
await context.Response.WriteAsync(name);
|
await context.Response.WriteAsync(name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Map("/418", context =>
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status418ImATeapot;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<HttpContext> SendAsync(TestServer server, string path, TestConnection connection, string authorizationHeader = null)
|
private static Task<HttpContext> SendAsync(TestServer server, string path, TestConnection connection, string authorizationHeader = null)
|
||||||
|
|
@ -352,7 +510,7 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
return new GenericIdentity("name", _protocol);
|
return new GenericIdentity("name", _protocol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetOutgoingBlob(string incomingBlob)
|
public string GetOutgoingBlob(string incomingBlob, out BlobErrorType errorType, out Exception ex)
|
||||||
{
|
{
|
||||||
if (IsDisposed)
|
if (IsDisposed)
|
||||||
{
|
{
|
||||||
|
|
@ -362,6 +520,10 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Authentication is already complete.");
|
throw new InvalidOperationException("Authentication is already complete.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
errorType = BlobErrorType.None;
|
||||||
|
ex = null;
|
||||||
|
|
||||||
switch (incomingBlob)
|
switch (incomingBlob)
|
||||||
{
|
{
|
||||||
case "ClientNtlmBlob1":
|
case "ClientNtlmBlob1":
|
||||||
|
|
@ -391,8 +553,22 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
Assert.Equal("Kerberos", _protocol);
|
Assert.Equal("Kerberos", _protocol);
|
||||||
IsCompleted = true;
|
IsCompleted = true;
|
||||||
return "ServerKerberosBlob2";
|
return "ServerKerberosBlob2";
|
||||||
|
case "CredentialError":
|
||||||
|
errorType = BlobErrorType.CredentialError;
|
||||||
|
ex = new Exception("A test credential error occured");
|
||||||
|
return null;
|
||||||
|
case "ClientError":
|
||||||
|
errorType = BlobErrorType.ClientError;
|
||||||
|
ex = new Exception("A test client error occured");
|
||||||
|
return null;
|
||||||
|
case "OtherError":
|
||||||
|
errorType = BlobErrorType.Other;
|
||||||
|
ex = new Exception("A test other error occured");
|
||||||
|
return null;
|
||||||
default:
|
default:
|
||||||
throw new InvalidOperationException(incomingBlob);
|
errorType = BlobErrorType.Other;
|
||||||
|
ex = new InvalidOperationException(incomingBlob);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,39 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
Assert.False(result.Response.Headers.ContainsKey(HeaderNames.WWWAuthenticate));
|
Assert.False(result.Response.Headers.ContainsKey(HeaderNames.WWWAuthenticate));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CredentialError_401()
|
||||||
|
{
|
||||||
|
using var host = await CreateHostAsync();
|
||||||
|
var server = host.GetTestServer();
|
||||||
|
var testConnection = new TestConnection();
|
||||||
|
var result = await SendAsync(server, "/Authenticate", testConnection, "Negotiate CredentialError");
|
||||||
|
Assert.Equal(StatusCodes.Status401Unauthorized, result.Response.StatusCode);
|
||||||
|
Assert.Equal("Negotiate", result.Response.Headers[HeaderNames.WWWAuthenticate]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientError_400()
|
||||||
|
{
|
||||||
|
using var host = await CreateHostAsync();
|
||||||
|
var server = host.GetTestServer();
|
||||||
|
var testConnection = new TestConnection();
|
||||||
|
var result = await SendAsync(server, "/404", testConnection, "Negotiate ClientError");
|
||||||
|
Assert.Equal(StatusCodes.Status400BadRequest, result.Response.StatusCode);
|
||||||
|
Assert.DoesNotContain(HeaderNames.WWWAuthenticate, result.Response.Headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OtherError_Throws()
|
||||||
|
{
|
||||||
|
using var host = await CreateHostAsync();
|
||||||
|
var server = host.GetTestServer();
|
||||||
|
var testConnection = new TestConnection();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<Exception>(() => SendAsync(server, "/404", testConnection, "Negotiate OtherError"));
|
||||||
|
Assert.Equal("A test other error occured", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
// Single Stage
|
// Single Stage
|
||||||
private static async Task KerberosAuth(TestServer server, TestConnection testConnection)
|
private static async Task KerberosAuth(TestServer server, TestConnection testConnection)
|
||||||
{
|
{
|
||||||
|
|
@ -474,7 +507,7 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
return new GenericIdentity("name", _protocol);
|
return new GenericIdentity("name", _protocol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetOutgoingBlob(string incomingBlob)
|
public string GetOutgoingBlob(string incomingBlob, out BlobErrorType errorType, out Exception ex)
|
||||||
{
|
{
|
||||||
if (IsDisposed)
|
if (IsDisposed)
|
||||||
{
|
{
|
||||||
|
|
@ -484,6 +517,10 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Authentication is already complete.");
|
throw new InvalidOperationException("Authentication is already complete.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
errorType = BlobErrorType.None;
|
||||||
|
ex = null;
|
||||||
|
|
||||||
switch (incomingBlob)
|
switch (incomingBlob)
|
||||||
{
|
{
|
||||||
case "ClientNtlmBlob1":
|
case "ClientNtlmBlob1":
|
||||||
|
|
@ -513,8 +550,22 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
Assert.Equal("Kerberos", _protocol);
|
Assert.Equal("Kerberos", _protocol);
|
||||||
IsCompleted = true;
|
IsCompleted = true;
|
||||||
return "ServerKerberosBlob2";
|
return "ServerKerberosBlob2";
|
||||||
|
case "CredentialError":
|
||||||
|
errorType = BlobErrorType.CredentialError;
|
||||||
|
ex = new Exception("A test credential error occured");
|
||||||
|
return null;
|
||||||
|
case "ClientError":
|
||||||
|
errorType = BlobErrorType.ClientError;
|
||||||
|
ex = new Exception("A test client error occured");
|
||||||
|
return null;
|
||||||
|
case "OtherError":
|
||||||
|
errorType = BlobErrorType.Other;
|
||||||
|
ex = new Exception("A test other error occured");
|
||||||
|
return null;
|
||||||
default:
|
default:
|
||||||
throw new InvalidOperationException(incomingBlob);
|
errorType = BlobErrorType.Other;
|
||||||
|
ex = new InvalidOperationException(incomingBlob);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue