diff --git a/src/Http/Authentication.Abstractions/src/IAuthenticationService.cs b/src/Http/Authentication.Abstractions/src/IAuthenticationService.cs index 5e1228593a87..630fd059199c 100644 --- a/src/Http/Authentication.Abstractions/src/IAuthenticationService.cs +++ b/src/Http/Authentication.Abstractions/src/IAuthenticationService.cs @@ -40,7 +40,7 @@ public interface IAuthenticationService Task ForbidAsync(HttpContext context, string? scheme, AuthenticationProperties? properties); /// - /// Sign a principal in for the specified authentication scheme. + /// Sign in a principal in for the specified authentication scheme. /// /// The . /// The name of the authentication scheme. diff --git a/src/Http/Authentication.Core/src/AuthenticationService.cs b/src/Http/Authentication.Core/src/AuthenticationService.cs index 3b45ffc56dc6..dec4279a7c56 100644 --- a/src/Http/Authentication.Core/src/AuthenticationService.cs +++ b/src/Http/Authentication.Core/src/AuthenticationService.cs @@ -145,7 +145,7 @@ public virtual async Task ForbidAsync(HttpContext context, string? scheme, Authe } /// - /// Sign a principal in for the specified authentication scheme. + /// Sign in a principal in for the specified authentication scheme. /// /// The . /// The name of the authentication scheme. diff --git a/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj b/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj index f3faa30a2af4..503a2ab0e90e 100644 --- a/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj +++ b/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj @@ -13,7 +13,8 @@ - + + diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index 66f06c4d3465..18b7c6a5dd48 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -7,6 +7,7 @@ using System.Text; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -24,6 +25,7 @@ public class SignInManager where TUser : class private readonly IHttpContextAccessor _contextAccessor; private readonly IAuthenticationSchemeProvider _schemes; private readonly IUserConfirmation _confirmation; + private readonly SignInManagerMetrics? _metrics; private HttpContext? _context; private TwoFactorAuthenticationInfo? _twoFactorInfo; @@ -56,6 +58,7 @@ public SignInManager(UserManager userManager, Logger = logger; _schemes = schemes; _confirmation = confirmation; + _metrics = userManager.ServiceProvider?.GetService(); } /// @@ -160,12 +163,26 @@ public virtual async Task CanSignInAsync(TUser user) /// The user to sign-in. /// The task object representing the asynchronous operation. public virtual async Task RefreshSignInAsync(TUser user) + { + try + { + var (success, isPersistent) = await RefreshSignInCoreAsync(user); + _metrics?.RefreshSignIn(typeof(TUser).FullName!, AuthenticationScheme, success, isPersistent); + } + catch (Exception ex) + { + _metrics?.RefreshSignIn(typeof(TUser).FullName!, AuthenticationScheme, success: null, isPersistent: null, ex); + throw; + } + } + + private async Task<(bool success, bool? isPersistent)> RefreshSignInCoreAsync(TUser user) { var auth = await Context.AuthenticateAsync(AuthenticationScheme); if (!auth.Succeeded || auth.Principal?.Identity?.IsAuthenticated != true) { Logger.LogError("RefreshSignInAsync prevented because the user is not currently authenticated. Use SignInAsync instead for initial sign in."); - return; + return (false, auth.Properties?.IsPersistent); } var authenticatedUserId = UserManager.GetUserId(auth.Principal); @@ -173,12 +190,12 @@ public virtual async Task RefreshSignInAsync(TUser user) if (authenticatedUserId == null || authenticatedUserId != newUserId) { Logger.LogError("RefreshSignInAsync prevented because currently authenticated user has a different UserId. Use SignInAsync instead to change users."); - return; + return (false, auth.Properties?.IsPersistent); } IList claims = Array.Empty(); - var authenticationMethod = auth?.Principal?.FindFirst(ClaimTypes.AuthenticationMethod); - var amr = auth?.Principal?.FindFirst("amr"); + var authenticationMethod = auth.Principal?.FindFirst(ClaimTypes.AuthenticationMethod); + var amr = auth.Principal?.FindFirst("amr"); if (authenticationMethod != null || amr != null) { @@ -193,7 +210,8 @@ public virtual async Task RefreshSignInAsync(TUser user) } } - await SignInWithClaimsAsync(user, auth?.Properties, claims); + await SignInWithClaimsAsync(user, auth.Properties, claims); + return (true, auth.Properties?.IsPersistent ?? false); } /// @@ -245,17 +263,27 @@ public virtual Task SignInWithClaimsAsync(TUser user, bool isPersistent, IEnumer /// The task object representing the asynchronous operation. public virtual async Task SignInWithClaimsAsync(TUser user, AuthenticationProperties? authenticationProperties, IEnumerable additionalClaims) { - var userPrincipal = await CreateUserPrincipalAsync(user); - foreach (var claim in additionalClaims) + try { - userPrincipal.Identities.First().AddClaim(claim); - } - await Context.SignInAsync(AuthenticationScheme, - userPrincipal, - authenticationProperties ?? new AuthenticationProperties()); + var userPrincipal = await CreateUserPrincipalAsync(user); + foreach (var claim in additionalClaims) + { + userPrincipal.Identities.First().AddClaim(claim); + } + await Context.SignInAsync(AuthenticationScheme, + userPrincipal, + authenticationProperties ?? new AuthenticationProperties()); - // This is useful for updating claims immediately when hitting MapIdentityApi's /account/info endpoint with cookies. - Context.User = userPrincipal; + // This is useful for updating claims immediately when hitting MapIdentityApi's /account/info endpoint with cookies. + Context.User = userPrincipal; + + _metrics?.SignInUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme); + } + catch (Exception ex) + { + _metrics?.SignInUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme, ex); + throw; + } } /// @@ -263,15 +291,25 @@ await Context.SignInAsync(AuthenticationScheme, /// public virtual async Task SignOutAsync() { - await Context.SignOutAsync(AuthenticationScheme); - - if (await _schemes.GetSchemeAsync(IdentityConstants.ExternalScheme) != null) + try { - await Context.SignOutAsync(IdentityConstants.ExternalScheme); + await Context.SignOutAsync(AuthenticationScheme); + + if (await _schemes.GetSchemeAsync(IdentityConstants.ExternalScheme) != null) + { + await Context.SignOutAsync(IdentityConstants.ExternalScheme); + } + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) + { + await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + } + + _metrics?.SignOutUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme); } - if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) + catch (Exception ex) { - await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + _metrics?.SignOutUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme, ex); + throw; } } @@ -345,12 +383,23 @@ public virtual async Task ValidateSecurityStampAsync(TUser? user, string? public virtual async Task PasswordSignInAsync(TUser user, string password, bool isPersistent, bool lockoutOnFailure) { - ArgumentNullException.ThrowIfNull(user); + try + { + ArgumentNullException.ThrowIfNull(user); + + var attempt = await CheckPasswordSignInAsync(user, password, lockoutOnFailure); + var result = attempt.Succeeded + ? await SignInOrTwoFactorAsync(user, isPersistent) + : attempt; + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.Password, isPersistent); - var attempt = await CheckPasswordSignInAsync(user, password, lockoutOnFailure); - return attempt.Succeeded - ? await SignInOrTwoFactorAsync(user, isPersistent) - : attempt; + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Password, isPersistent, ex); + throw; + } } /// @@ -369,6 +418,7 @@ public virtual async Task PasswordSignInAsync(string userName, str var user = await UserManager.FindByNameAsync(userName); if (user == null) { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, SignInResult.Failed, SignInType.Password, isPersistent); return SignInResult.Failed; } @@ -386,8 +436,24 @@ public virtual async Task PasswordSignInAsync(string userName, str /// public virtual async Task CheckPasswordSignInAsync(TUser user, string password, bool lockoutOnFailure) { - ArgumentNullException.ThrowIfNull(user); + try + { + ArgumentNullException.ThrowIfNull(user); + + var result = await CheckPasswordSignInCoreAsync(user, password, lockoutOnFailure); + _metrics?.CheckPasswordSignIn(typeof(TUser).FullName!, result); + return result; + } + catch (Exception ex) + { + _metrics?.CheckPasswordSignIn(typeof(TUser).FullName!, result: null, ex); + throw; + } + } + + private async Task CheckPasswordSignInCoreAsync(TUser user, string password, bool lockoutOnFailure) + { var error = await PreSignInCheck(user); if (error != null) { @@ -461,19 +527,37 @@ public virtual async Task IsTwoFactorClientRememberedAsync(TUser user) /// The task object representing the asynchronous operation. public virtual async Task RememberTwoFactorClientAsync(TUser user) { - var principal = await StoreRememberClient(user); - await Context.SignInAsync(IdentityConstants.TwoFactorRememberMeScheme, - principal, - new AuthenticationProperties { IsPersistent = true }); + try + { + var principal = await StoreRememberClient(user); + await Context.SignInAsync(IdentityConstants.TwoFactorRememberMeScheme, + principal, + new AuthenticationProperties { IsPersistent = true }); + _metrics?.RememberTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme); + } + catch (Exception ex) + { + _metrics?.RememberTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme, ex); + throw; + } } /// /// Clears the "Remember this browser flag" from the current browser, as an asynchronous operation. /// /// The task object representing the asynchronous operation. - public virtual Task ForgetTwoFactorClientAsync() + public virtual async Task ForgetTwoFactorClientAsync() { - return Context.SignOutAsync(IdentityConstants.TwoFactorRememberMeScheme); + try + { + await Context.SignOutAsync(IdentityConstants.TwoFactorRememberMeScheme); + _metrics?.ForgetTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme); + } + catch (Exception ex) + { + _metrics?.ForgetTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme, ex); + throw; + } } /// @@ -482,6 +566,22 @@ public virtual Task ForgetTwoFactorClientAsync() /// The two factor recovery code. /// public virtual async Task TwoFactorRecoveryCodeSignInAsync(string recoveryCode) + { + try + { + var result = await TwoFactorRecoveryCodeSignInCoreAsync(recoveryCode); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactorRecoveryCode, isPersistent: false); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactorRecoveryCode, isPersistent: false, ex); + throw; + } + } + + private async Task TwoFactorRecoveryCodeSignInCoreAsync(string recoveryCode) { var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); if (twoFactorInfo == null) @@ -510,8 +610,10 @@ private async Task DoTwoFactorSignInAsync(TUser user, TwoFactorAut return SignInResult.Failed; } - var claims = new List(); - claims.Add(new Claim("amr", "mfa")); + var claims = new List + { + new Claim("amr", "mfa") + }; if (twoFactorInfo.LoginProvider != null) { @@ -545,6 +647,22 @@ private async Task DoTwoFactorSignInAsync(TUser user, TwoFactorAut /// The task object representing the asynchronous operation containing the /// for the sign-in attempt. public virtual async Task TwoFactorAuthenticatorSignInAsync(string code, bool isPersistent, bool rememberClient) + { + try + { + var result = await TwoFactorAuthenticatorSignInCoreAsync(code, isPersistent, rememberClient); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactorAuthenticator, isPersistent); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactorAuthenticator, isPersistent, ex); + throw; + } + } + + private async Task TwoFactorAuthenticatorSignInCoreAsync(string code, bool isPersistent, bool rememberClient) { var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); if (twoFactorInfo == null) @@ -593,6 +711,22 @@ public virtual async Task TwoFactorAuthenticatorSignInAsync(string /// The task object representing the asynchronous operation containing the /// for the sign-in attempt. public virtual async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient) + { + try + { + var result = await TwoFactorSignInCoreAsync(provider, code, isPersistent, rememberClient); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactor, isPersistent); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactor, isPersistent, ex); + throw; + } + } + + private async Task TwoFactorSignInCoreAsync(string provider, string code, bool isPersistent, bool rememberClient) { var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); if (twoFactorInfo == null) @@ -666,6 +800,22 @@ public virtual Task ExternalLoginSignInAsync(string loginProvider, /// The task object representing the asynchronous operation containing the /// for the sign-in attempt. public virtual async Task ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor) + { + try + { + var result = await ExternalLoginSignInCoreAsync(loginProvider, providerKey, isPersistent, bypassTwoFactor); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.External, isPersistent); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.External, isPersistent, ex); + throw; + } + } + + private async Task ExternalLoginSignInCoreAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor) { var user = await UserManager.FindByLoginAsync(loginProvider, providerKey); if (user == null) diff --git a/src/Identity/Core/src/SignInManagerMetrics.cs b/src/Identity/Core/src/SignInManagerMetrics.cs new file mode 100644 index 000000000000..d4b82dfcf015 --- /dev/null +++ b/src/Identity/Core/src/SignInManagerMetrics.cs @@ -0,0 +1,192 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace Microsoft.AspNetCore.Identity; + +internal sealed class SignInManagerMetrics : IDisposable +{ + public const string MeterName = "Microsoft.AspNetCore.Identity"; + + public const string AuthenticateCounterName = "aspnetcore.identity.sign_in.authenticate"; + public const string RememberTwoFactorCounterName = "aspnetcore.identity.sign_in.remember_two_factor"; + public const string ForgetTwoFactorCounterName = "aspnetcore.identity.sign_in.forget_two_factor"; + public const string RefreshCounterName = "aspnetcore.identity.sign_in.refresh"; + public const string CheckPasswordCounterName = "aspnetcore.identity.sign_in.check_password"; + public const string SignInUserPrincipalCounterName = "aspnetcore.identity.sign_in.sign_in_principal"; + public const string SignOutUserPrincipalCounterName = "aspnetcore.identity.sign_in.sign_out_principal"; + + private readonly Meter _meter; + private readonly Counter _authenticateCounter; + private readonly Counter _rememberTwoFactorClientCounter; + private readonly Counter _forgetTwoFactorCounter; + private readonly Counter _refreshCounter; + private readonly Counter _checkPasswordCounter; + private readonly Counter _signInUserPrincipalCounter; + private readonly Counter _signOutUserPrincipalCounter; + + public SignInManagerMetrics(IMeterFactory meterFactory) + { + _meter = meterFactory.Create(MeterName); + + _authenticateCounter = _meter.CreateCounter(AuthenticateCounterName, "count", "The number of authenticate and sign in attempts."); + _rememberTwoFactorClientCounter = _meter.CreateCounter(RememberTwoFactorCounterName, "count", "The number of two factor clients remembered."); + _forgetTwoFactorCounter = _meter.CreateCounter(ForgetTwoFactorCounterName, "count", "The number of two factor clients forgotten."); + _refreshCounter = _meter.CreateCounter(RefreshCounterName, "count", "The number of refresh sign-in attempts."); + _checkPasswordCounter = _meter.CreateCounter(CheckPasswordCounterName, "count", "The number of check password attempts."); + _signInUserPrincipalCounter = _meter.CreateCounter(SignInUserPrincipalCounterName, "count", "The number of user principals signed in."); + _signOutUserPrincipalCounter = _meter.CreateCounter(SignOutUserPrincipalCounterName, "count", "The number of user principals signed out."); + } + + internal void CheckPasswordSignIn(string userType, SignInResult? result, Exception? exception = null) + { + var tags = new TagList + { + { "aspnetcore.identity.user_type", userType }, + }; + AddSignInResult(ref tags, result); + AddExceptionTags(ref tags, exception); + + _checkPasswordCounter.Add(1, tags); + } + + internal void AuthenticateSignIn(string userType, string authenticationScheme, SignInResult? result, SignInType signInType, bool isPersistent, Exception? exception = null) + { + var tags = new TagList + { + { "aspnetcore.identity.user_type", userType }, + { "aspnetcore.identity.authentication_scheme", authenticationScheme }, + { "aspnetcore.identity.sign_in.type", GetSignInType(signInType) }, + { "aspnetcore.identity.sign_in.is_persistent", isPersistent }, + }; + if (result != null) + { + tags.Add("aspnetcore.identity.sign_in.result", GetSignInResult(result)); + } + AddExceptionTags(ref tags, exception); + + _authenticateCounter.Add(1, tags); + } + + internal void SignInUserPrincipal(string userType, string authenticationScheme, Exception? exception = null) + { + var tags = new TagList + { + { "aspnetcore.identity.user_type", userType }, + { "aspnetcore.identity.authentication_scheme", authenticationScheme }, + }; + AddExceptionTags(ref tags, exception); + + _signInUserPrincipalCounter.Add(1, tags); + } + + internal void SignOutUserPrincipal(string userType, string authenticationScheme, Exception? exception = null) + { + var tags = new TagList + { + { "aspnetcore.identity.user_type", userType }, + { "aspnetcore.identity.authentication_scheme", authenticationScheme }, + }; + AddExceptionTags(ref tags, exception); + + _signOutUserPrincipalCounter.Add(1, tags); + } + + internal void RememberTwoFactorClient(string userType, string authenticationScheme, Exception? exception = null) + { + var tags = new TagList + { + { "aspnetcore.identity.user_type", userType }, + { "aspnetcore.identity.authentication_scheme", authenticationScheme } + }; + AddExceptionTags(ref tags, exception); + + _rememberTwoFactorClientCounter.Add(1, tags); + } + + internal void ForgetTwoFactorClient(string userType, string authenticationScheme, Exception? exception = null) + { + var tags = new TagList + { + { "aspnetcore.identity.user_type", userType }, + { "aspnetcore.identity.authentication_scheme", authenticationScheme } + }; + AddExceptionTags(ref tags, exception); + + _forgetTwoFactorCounter.Add(1, tags); + } + + internal void RefreshSignIn(string userType, string authenticationScheme, bool? success, bool? isPersistent, Exception? exception = null) + { + var tags = new TagList + { + { "aspnetcore.identity.user_type", userType }, + { "aspnetcore.identity.authentication_scheme", authenticationScheme }, + { "aspnetcore.identity.sign_in.result", success.GetValueOrDefault() ? "success" : "failure" } + }; + if (isPersistent != null) + { + tags.Add("aspnetcore.identity.sign_in.is_persistent", isPersistent.Value); + } + AddExceptionTags(ref tags, exception); + + _refreshCounter.Add(1, tags); + } + + public void Dispose() + { + _meter.Dispose(); + } + + private static void AddSignInResult(ref TagList tags, SignInResult? result) + { + if (result != null) + { + tags.Add("aspnetcore.identity.sign_in.result", GetSignInResult(result)); + } + } + + private static void AddExceptionTags(ref TagList tags, Exception? exception) + { + if (exception != null) + { + tags.Add("error.type", exception.GetType().FullName!); + } + } + + private static string GetSignInType(SignInType signInType) + { + return signInType switch + { + SignInType.Password => "password", + SignInType.TwoFactorRecoveryCode => "two_factor_recovery_code", + SignInType.TwoFactorAuthenticator => "two_factor_authenticator", + SignInType.TwoFactor => "two_factor", + SignInType.External => "external", + _ => "_UNKNOWN" + }; + } + + private static string GetSignInResult(SignInResult result) + { + return result switch + { + { Succeeded: true } => "success", + { IsLockedOut: true } => "locked_out", + { IsNotAllowed: true } => "not_allowed", + { RequiresTwoFactor: true } => "requires_two_factor", + _ => "failure" + }; + } +} + +internal enum SignInType +{ + Password, + TwoFactorRecoveryCode, + TwoFactorAuthenticator, + TwoFactor, + External +} diff --git a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt index 58c861652420..e24860e08367 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ #nullable enable *REMOVED*Microsoft.AspNetCore.Identity.UserLoginInfo.UserLoginInfo(string! loginProvider, string! providerKey, string? displayName) -> void Microsoft.AspNetCore.Identity.UserLoginInfo.UserLoginInfo(string! loginProvider, string! providerKey, string? providerDisplayName) -> void +Microsoft.AspNetCore.Identity.UserManager.ServiceProvider.get -> System.IServiceProvider! diff --git a/src/Identity/Extensions.Core/src/UserManager.cs b/src/Identity/Extensions.Core/src/UserManager.cs index cca2005d10d0..94a292d02176 100644 --- a/src/Identity/Extensions.Core/src/UserManager.cs +++ b/src/Identity/Extensions.Core/src/UserManager.cs @@ -47,7 +47,7 @@ public class UserManager : IDisposable where TUser : class #if NETSTANDARD2_0 || NETFRAMEWORK private static readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); #endif - private readonly IServiceProvider _services; + private readonly UserManagerMetrics? _metrics; /// /// The cancellation token used to cancel operations. @@ -83,6 +83,8 @@ public UserManager(IUserStore store, KeyNormalizer = keyNormalizer; ErrorDescriber = errors; Logger = logger; + ServiceProvider = services; + _metrics = services?.GetService(); if (userValidators != null) { @@ -99,7 +101,6 @@ public UserManager(IUserStore store, } } - _services = services; if (services != null) { foreach (var providerName in Options.Tokens.ProviderMap.Keys) @@ -176,6 +177,11 @@ public UserManager(IUserStore store, /// public IdentityOptions Options { get; set; } + /// + /// The used to resolve Identity services. + /// + public IServiceProvider ServiceProvider { get; } + /// /// Gets a flag indicating whether the backing user store supports authentication tokens. /// @@ -459,13 +465,28 @@ public virtual Task GenerateConcurrencyStampAsync(TUser user) /// of the operation. /// public virtual async Task CreateAsync(TUser user) + { + try + { + var result = await CreateCoreAsync(user).ConfigureAwait(false); + _metrics?.CreateUser(typeof(TUser).FullName!, result); + return result; + } + catch (Exception ex) + { + _metrics?.CreateUser(typeof(TUser).FullName!, result: null, ex); + throw; + } + } + + private async Task CreateCoreAsync(TUser user) { ThrowIfDisposed(); await UpdateSecurityStampInternal(user).ConfigureAwait(false); - var result = await ValidateUserAsync(user).ConfigureAwait(false); - if (!result.Succeeded) + var validateUserResult = await ValidateUserAsync(user).ConfigureAwait(false); + if (!validateUserResult.Succeeded) { - return result; + return validateUserResult; } if (Options.Lockout.AllowedForNewUsers && SupportsUserLockout) { @@ -485,12 +506,20 @@ public virtual async Task CreateAsync(TUser user) /// The that represents the asynchronous operation, containing the /// of the operation. /// - public virtual Task UpdateAsync(TUser user) + public virtual async Task UpdateAsync(TUser user) { - ThrowIfDisposed(); - ArgumentNullThrowHelper.ThrowIfNull(user); + try + { + ThrowIfDisposed(); + ArgumentNullThrowHelper.ThrowIfNull(user); - return UpdateUserAsync(user); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.Update).ConfigureAwait(false); + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.Update, ex); + throw; + } } /// @@ -501,12 +530,23 @@ public virtual Task UpdateAsync(TUser user) /// The that represents the asynchronous operation, containing the /// of the operation. /// - public virtual Task DeleteAsync(TUser user) + public virtual async Task DeleteAsync(TUser user) { - ThrowIfDisposed(); - ArgumentNullThrowHelper.ThrowIfNull(user); + try + { + ThrowIfDisposed(); + ArgumentNullThrowHelper.ThrowIfNull(user); + + var result = await Store.DeleteAsync(user, CancellationToken).ConfigureAwait(false); + _metrics?.DeleteUser(typeof(TUser).FullName!, result); - return Store.DeleteAsync(user, CancellationToken); + return result; + } + catch (Exception ex) + { + _metrics?.DeleteUser(typeof(TUser).FullName!, result: null, ex); + throw; + } } /// @@ -540,8 +580,8 @@ public virtual Task DeleteAsync(TUser user) // Need to potentially check all keys if (user == null && Options.Stores.ProtectPersonalData) { - var keyRing = _services.GetService(); - var protector = _services.GetService(); + var keyRing = ServiceProvider.GetService(); + var protector = ServiceProvider.GetService(); if (keyRing != null && protector != null) { foreach (var key in keyRing.GetAllKeyIds()) @@ -570,15 +610,26 @@ public virtual Task DeleteAsync(TUser user) /// public virtual async Task CreateAsync(TUser user, string password) { - ThrowIfDisposed(); - var passwordStore = GetPasswordStore(); - ArgumentNullThrowHelper.ThrowIfNull(user); - ArgumentNullThrowHelper.ThrowIfNull(password); - var result = await UpdatePasswordHash(passwordStore, user, password).ConfigureAwait(false); - if (!result.Succeeded) + try { - return result; + ThrowIfDisposed(); + var passwordStore = GetPasswordStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + ArgumentNullThrowHelper.ThrowIfNull(password); + var result = await UpdatePasswordHash(passwordStore, user, password).ConfigureAwait(false); + if (!result.Succeeded) + { + _metrics?.CreateUser(typeof(TUser).FullName!, result); + return result; + } } + catch (Exception ex) + { + _metrics?.CreateUser(typeof(TUser).FullName!, result: null, ex); + throw; + } + + // Already has a try/catch. return await CreateAsync(user).ConfigureAwait(false); } @@ -605,8 +656,8 @@ public virtual async Task CreateAsync(TUser user, string passwor { if (Options.Stores.ProtectPersonalData) { - var keyRing = _services.GetRequiredService(); - var protector = _services.GetRequiredService(); + var keyRing = ServiceProvider.GetRequiredService(); + var protector = ServiceProvider.GetRequiredService(); return protector.Protect(keyRing.CurrentKeyId, data); } return data; @@ -644,12 +695,20 @@ public virtual async Task UpdateNormalizedUserNameAsync(TUser user) /// The that represents the asynchronous operation. public virtual async Task SetUserNameAsync(TUser user, string? userName) { - ThrowIfDisposed(); - ArgumentNullThrowHelper.ThrowIfNull(user); + try + { + ThrowIfDisposed(); + ArgumentNullThrowHelper.ThrowIfNull(user); - await Store.SetUserNameAsync(user, userName, CancellationToken).ConfigureAwait(false); - await UpdateSecurityStampInternal(user).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + await Store.SetUserNameAsync(user, userName, CancellationToken).ConfigureAwait(false); + await UpdateSecurityStampInternal(user).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.UserName).ConfigureAwait(false); + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.UserName, ex); + throw; + } } /// @@ -673,27 +732,45 @@ public virtual async Task GetUserIdAsync(TUser user) /// the specified matches the one store for the , /// otherwise false. public virtual async Task CheckPasswordAsync(TUser user, string password) + { + try + { + var (result, userMissing) = await CheckPasswordCoreAsync(user, password).ConfigureAwait(false); + _metrics?.CheckPassword(typeof(TUser).FullName!, userMissing, result); + + if (result == PasswordVerificationResult.Failed) + { + Logger.LogDebug(LoggerEventIds.InvalidPassword, "Invalid password for user."); + return false; + } + + return result != null; + } + catch (Exception ex) + { + _metrics?.CheckPassword(typeof(TUser).FullName!, userMissing: null, result: null, ex); + throw; + } + } + + private async Task<(PasswordVerificationResult? result, bool userMissing)> CheckPasswordCoreAsync(TUser user, string password) { ThrowIfDisposed(); var passwordStore = GetPasswordStore(); if (user == null) { - return false; + return (null, true); } var result = await VerifyPasswordAsync(passwordStore, user, password).ConfigureAwait(false); + if (result == PasswordVerificationResult.SuccessRehashNeeded) { await UpdatePasswordHash(passwordStore, user, password, validatePassword: false).ConfigureAwait(false); - await UpdateUserAsync(user).ConfigureAwait(false); + await UpdateUserAndRecordMetricAsync(user, UserUpdateType.PasswordRehash).ConfigureAwait(false); } - var success = result != PasswordVerificationResult.Failed; - if (!success) - { - Logger.LogDebug(LoggerEventIds.InvalidPassword, "Invalid password for user."); - } - return success; + return (result, false); } /// @@ -724,6 +801,21 @@ public virtual Task HasPasswordAsync(TUser user) /// of the operation. /// public virtual async Task AddPasswordAsync(TUser user, string password) + { + try + { + var result = await AddPasswordCoreAsync(user, password).ConfigureAwait(false); + _metrics?.UpdateUser(typeof(TUser).FullName!, result, UserUpdateType.AddPassword); + return result; + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.AddPassword, ex); + throw; + } + } + + private async Task AddPasswordCoreAsync(TUser user, string password) { ThrowIfDisposed(); var passwordStore = GetPasswordStore(); @@ -755,6 +847,21 @@ public virtual async Task AddPasswordAsync(TUser user, string pa /// of the operation. /// public virtual async Task ChangePasswordAsync(TUser user, string currentPassword, string newPassword) + { + try + { + var result = await ChangePasswordCoreAsync(user, currentPassword, newPassword).ConfigureAwait(false); + _metrics?.UpdateUser(typeof(TUser).FullName!, result, UserUpdateType.ChangePassword); + return result; + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.ChangePassword, ex); + throw; + } + } + + private async Task ChangePasswordCoreAsync(TUser user, string currentPassword, string newPassword) { ThrowIfDisposed(); var passwordStore = GetPasswordStore(); @@ -762,10 +869,10 @@ public virtual async Task ChangePasswordAsync(TUser user, string if (await VerifyPasswordAsync(passwordStore, user, currentPassword).ConfigureAwait(false) != PasswordVerificationResult.Failed) { - var result = await UpdatePasswordHash(passwordStore, user, newPassword).ConfigureAwait(false); - if (!result.Succeeded) + var updateResult = await UpdatePasswordHash(passwordStore, user, newPassword).ConfigureAwait(false); + if (!updateResult.Succeeded) { - return result; + return updateResult; } return await UpdateUserAsync(user).ConfigureAwait(false); } @@ -783,12 +890,20 @@ public virtual async Task ChangePasswordAsync(TUser user, string /// public virtual async Task RemovePasswordAsync(TUser user) { - ThrowIfDisposed(); - var passwordStore = GetPasswordStore(); - ArgumentNullThrowHelper.ThrowIfNull(user); + try + { + ThrowIfDisposed(); + var passwordStore = GetPasswordStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); - await UpdatePasswordHash(passwordStore, user, null, validatePassword: false).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + await UpdatePasswordHash(passwordStore, user, null, validatePassword: false).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.RemovePassword).ConfigureAwait(false); + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.RemovePassword, ex); + throw; + } } /// @@ -806,9 +921,13 @@ protected virtual async Task VerifyPasswordAsync(IUs var hash = await store.GetPasswordHashAsync(user, CancellationToken).ConfigureAwait(false); if (hash == null) { + _metrics?.VerifyPassword(typeof(TUser).FullName!, passwordMissing: true, result: null); return PasswordVerificationResult.Failed; } - return PasswordHasher.VerifyHashedPassword(user, hash, password); + var result = PasswordHasher.VerifyHashedPassword(user, hash, password); + _metrics?.VerifyPassword(typeof(TUser).FullName!, passwordMissing: false, result); + + return result; } /// @@ -843,12 +962,20 @@ public virtual async Task GetSecurityStampAsync(TUser user) /// public virtual async Task UpdateSecurityStampAsync(TUser user) { - ThrowIfDisposed(); - GetSecurityStore(); - ArgumentNullThrowHelper.ThrowIfNull(user); + try + { + ThrowIfDisposed(); + GetSecurityStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); - await UpdateSecurityStampInternal(user).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + await UpdateSecurityStampInternal(user).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.SecurityStamp).ConfigureAwait(false); + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.SecurityStamp, ex); + throw; + } } /// @@ -883,14 +1010,18 @@ public virtual async Task ResetPasswordAsync(TUser user, string // Make sure the token is valid and the stamp matches if (!await VerifyUserTokenAsync(user, Options.Tokens.PasswordResetTokenProvider, ResetPasswordTokenPurpose, token).ConfigureAwait(false)) { - return IdentityResult.Failed(ErrorDescriber.InvalidToken()); + var failureResult = IdentityResult.Failed(ErrorDescriber.InvalidToken()); + _metrics?.UpdateUser(typeof(TUser).FullName!, failureResult, UserUpdateType.ResetPassword); + + return failureResult; } var result = await UpdatePasswordHash(user, newPassword, validatePassword: true).ConfigureAwait(false); if (!result.Succeeded) { + _metrics?.UpdateUser(typeof(TUser).FullName!, result, UserUpdateType.ResetPassword); return result; } - return await UpdateUserAsync(user).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.ResetPassword).ConfigureAwait(false); } /// @@ -923,15 +1054,23 @@ public virtual async Task ResetPasswordAsync(TUser user, string /// public virtual async Task RemoveLoginAsync(TUser user, string loginProvider, string providerKey) { - ThrowIfDisposed(); - var loginStore = GetLoginStore(); - ArgumentNullThrowHelper.ThrowIfNull(loginProvider); - ArgumentNullThrowHelper.ThrowIfNull(providerKey); - ArgumentNullThrowHelper.ThrowIfNull(user); + try + { + ThrowIfDisposed(); + var loginStore = GetLoginStore(); + ArgumentNullThrowHelper.ThrowIfNull(loginProvider); + ArgumentNullThrowHelper.ThrowIfNull(providerKey); + ArgumentNullThrowHelper.ThrowIfNull(user); - await loginStore.RemoveLoginAsync(user, loginProvider, providerKey, CancellationToken).ConfigureAwait(false); - await UpdateSecurityStampInternal(user).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + await loginStore.RemoveLoginAsync(user, loginProvider, providerKey, CancellationToken).ConfigureAwait(false); + await UpdateSecurityStampInternal(user).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.RemoveLogin).ConfigureAwait(false); + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.RemoveLogin, ex); + throw; + } } /// @@ -944,6 +1083,21 @@ public virtual async Task RemoveLoginAsync(TUser user, string lo /// of the operation. /// public virtual async Task AddLoginAsync(TUser user, UserLoginInfo login) + { + try + { + var result = await AddLoginCoreAsync(user, login).ConfigureAwait(false); + _metrics?.UpdateUser(typeof(TUser).FullName!, result, UserUpdateType.AddLogin); + return result; + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.AddLogin, ex); + throw; + } + } + + private async Task AddLoginCoreAsync(TUser user, UserLoginInfo login) { ThrowIfDisposed(); var loginStore = GetLoginStore(); @@ -986,11 +1140,7 @@ public virtual async Task> GetLoginsAsync(TUser user) /// public virtual Task AddClaimAsync(TUser user, Claim claim) { - ThrowIfDisposed(); - GetClaimStore(); - ArgumentNullThrowHelper.ThrowIfNull(claim); - ArgumentNullThrowHelper.ThrowIfNull(user); - return AddClaimsAsync(user, new Claim[] { claim }); + return AddClaimsAsync(user, [claim]); } /// @@ -1004,13 +1154,21 @@ public virtual Task AddClaimAsync(TUser user, Claim claim) /// public virtual async Task AddClaimsAsync(TUser user, IEnumerable claims) { - ThrowIfDisposed(); - var claimStore = GetClaimStore(); - ArgumentNullThrowHelper.ThrowIfNull(claims); - ArgumentNullThrowHelper.ThrowIfNull(user); + try + { + ThrowIfDisposed(); + var claimStore = GetClaimStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + ArgumentNullThrowHelper.ThrowIfNull(claims); - await claimStore.AddClaimsAsync(user, claims, CancellationToken).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + await claimStore.AddClaimsAsync(user, claims, CancellationToken).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.AddClaims).ConfigureAwait(false); + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.AddClaims, ex); + throw; + } } /// @@ -1025,14 +1183,22 @@ public virtual async Task AddClaimsAsync(TUser user, IEnumerable /// public virtual async Task ReplaceClaimAsync(TUser user, Claim claim, Claim newClaim) { - ThrowIfDisposed(); - var claimStore = GetClaimStore(); - ArgumentNullThrowHelper.ThrowIfNull(claim); - ArgumentNullThrowHelper.ThrowIfNull(newClaim); - ArgumentNullThrowHelper.ThrowIfNull(user); + try + { + ThrowIfDisposed(); + var claimStore = GetClaimStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + ArgumentNullThrowHelper.ThrowIfNull(claim); + ArgumentNullThrowHelper.ThrowIfNull(newClaim); - await claimStore.ReplaceClaimAsync(user, claim, newClaim, CancellationToken).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + await claimStore.ReplaceClaimAsync(user, claim, newClaim, CancellationToken).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.ReplaceClaim).ConfigureAwait(false); + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.ReplaceClaim, ex); + throw; + } } /// @@ -1046,11 +1212,7 @@ public virtual async Task ReplaceClaimAsync(TUser user, Claim cl /// public virtual Task RemoveClaimAsync(TUser user, Claim claim) { - ThrowIfDisposed(); - GetClaimStore(); - ArgumentNullThrowHelper.ThrowIfNull(user); - ArgumentNullThrowHelper.ThrowIfNull(claim); - return RemoveClaimsAsync(user, new Claim[] { claim }); + return RemoveClaimsAsync(user, [claim]); } /// @@ -1064,13 +1226,21 @@ public virtual Task RemoveClaimAsync(TUser user, Claim claim) /// public virtual async Task RemoveClaimsAsync(TUser user, IEnumerable claims) { - ThrowIfDisposed(); - var claimStore = GetClaimStore(); - ArgumentNullThrowHelper.ThrowIfNull(user); - ArgumentNullThrowHelper.ThrowIfNull(claims); + try + { + ThrowIfDisposed(); + var claimStore = GetClaimStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + ArgumentNullThrowHelper.ThrowIfNull(claims); - await claimStore.RemoveClaimsAsync(user, claims, CancellationToken).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + await claimStore.RemoveClaimsAsync(user, claims, CancellationToken).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.RemoveClaims).ConfigureAwait(false); + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.RemoveClaims, ex); + throw; + } } /// @@ -1098,6 +1268,21 @@ public virtual async Task> GetClaimsAsync(TUser user) /// of the operation. /// public virtual async Task AddToRoleAsync(TUser user, string role) + { + try + { + var result = await AddToRoleCoreAsync(user, role).ConfigureAwait(false); + _metrics?.UpdateUser(typeof(TUser).FullName!, result, UserUpdateType.AddToRoles); + return result; + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.AddToRoles, ex); + throw; + } + } + + private async Task AddToRoleCoreAsync(TUser user, string role) { ThrowIfDisposed(); var userRoleStore = GetUserRoleStore(); @@ -1122,6 +1307,21 @@ public virtual async Task AddToRoleAsync(TUser user, string role /// of the operation. /// public virtual async Task AddToRolesAsync(TUser user, IEnumerable roles) + { + try + { + var result = await AddToRolesCoreAsync(user, roles).ConfigureAwait(false); + _metrics?.UpdateUser(typeof(TUser).FullName!, result, UserUpdateType.AddToRoles); + return result; + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.AddToRoles, ex); + throw; + } + } + + private async Task AddToRolesCoreAsync(TUser user, IEnumerable roles) { ThrowIfDisposed(); var userRoleStore = GetUserRoleStore(); @@ -1158,10 +1358,13 @@ public virtual async Task RemoveFromRoleAsync(TUser user, string var normalizedRole = NormalizeName(role); if (!await userRoleStore.IsInRoleAsync(user, normalizedRole, CancellationToken).ConfigureAwait(false)) { - return UserNotInRoleError(role); + var failureResult = UserNotInRoleError(role); + _metrics?.UpdateUser(typeof(TUser).FullName!, failureResult, UserUpdateType.RemoveFromRoles); + + return failureResult; } await userRoleStore.RemoveFromRoleAsync(user, normalizedRole, CancellationToken).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.RemoveFromRoles).ConfigureAwait(false); } private IdentityResult UserAlreadyInRoleError(string role) @@ -1192,6 +1395,21 @@ private IdentityResult UserNotInRoleError(string role) /// of the operation. /// public virtual async Task RemoveFromRolesAsync(TUser user, IEnumerable roles) + { + try + { + var result = await ResolveFromtRolesCoreAsync(user, roles).ConfigureAwait(false); + _metrics?.UpdateUser(typeof(TUser).FullName!, result, UserUpdateType.RemoveFromRoles); + return result; + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.RemoveFromRoles, ex); + throw; + } + } + + private async Task ResolveFromtRolesCoreAsync(TUser user, IEnumerable roles) { ThrowIfDisposed(); var userRoleStore = GetUserRoleStore(); @@ -1264,14 +1482,22 @@ public virtual async Task IsInRoleAsync(TUser user, string role) /// public virtual async Task SetEmailAsync(TUser user, string? email) { - ThrowIfDisposed(); - var store = GetEmailStore(); - ArgumentNullThrowHelper.ThrowIfNull(user); + try + { + ThrowIfDisposed(); + var store = GetEmailStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); - await store.SetEmailAsync(user, email, CancellationToken).ConfigureAwait(false); - await store.SetEmailConfirmedAsync(user, false, CancellationToken).ConfigureAwait(false); - await UpdateSecurityStampInternal(user).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + await store.SetEmailAsync(user, email, CancellationToken).ConfigureAwait(false); + await store.SetEmailConfirmedAsync(user, false, CancellationToken).ConfigureAwait(false); + await UpdateSecurityStampInternal(user).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.SetEmail).ConfigureAwait(false); + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.SetEmail, ex); + throw; + } } /// @@ -1295,8 +1521,8 @@ public virtual async Task SetEmailAsync(TUser user, string? emai // Need to potentially check all keys if (user == null && Options.Stores.ProtectPersonalData) { - var keyRing = _services.GetService(); - var protector = _services.GetService(); + var keyRing = ServiceProvider.GetService(); + var protector = ServiceProvider.GetService(); if (keyRing != null && protector != null) { foreach (var key in keyRing.GetAllKeyIds()) @@ -1351,6 +1577,21 @@ public virtual Task GenerateEmailConfirmationTokenAsync(TUser user) /// of the operation. /// public virtual async Task ConfirmEmailAsync(TUser user, string token) + { + try + { + var result = await ConfirmEmailCoreAsync(user, token).ConfigureAwait(false); + _metrics?.UpdateUser(typeof(TUser).FullName!, result, UserUpdateType.ConfirmEmail); + return result; + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.ConfirmEmail, ex); + throw; + } + } + + private async Task ConfirmEmailCoreAsync(TUser user, string token) { ThrowIfDisposed(); var store = GetEmailStore(); @@ -1406,6 +1647,21 @@ public virtual Task GenerateChangeEmailTokenAsync(TUser user, string new /// of the operation. /// public virtual async Task ChangeEmailAsync(TUser user, string newEmail, string token) + { + try + { + var result = await ChangeEmailCoreAsync(user, newEmail, token).ConfigureAwait(false); + _metrics?.UpdateUser(typeof(TUser).FullName!, result, UserUpdateType.ChangeEmail); + return result; + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.ChangeEmail, ex); + throw; + } + } + + private async Task ChangeEmailCoreAsync(TUser user, string newEmail, string token) { ThrowIfDisposed(); ArgumentNullThrowHelper.ThrowIfNull(user); @@ -1419,7 +1675,7 @@ public virtual async Task ChangeEmailAsync(TUser user, string ne await store.SetEmailAsync(user, newEmail, CancellationToken).ConfigureAwait(false); await store.SetEmailConfirmedAsync(user, true, CancellationToken).ConfigureAwait(false); await UpdateSecurityStampInternal(user).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.ChangeEmail).ConfigureAwait(false); } /// @@ -1446,14 +1702,22 @@ public virtual async Task ChangeEmailAsync(TUser user, string ne /// public virtual async Task SetPhoneNumberAsync(TUser user, string? phoneNumber) { - ThrowIfDisposed(); - var store = GetPhoneNumberStore(); - ArgumentNullThrowHelper.ThrowIfNull(user); + try + { + ThrowIfDisposed(); + var store = GetPhoneNumberStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); - await store.SetPhoneNumberAsync(user, phoneNumber, CancellationToken).ConfigureAwait(false); - await store.SetPhoneNumberConfirmedAsync(user, false, CancellationToken).ConfigureAwait(false); - await UpdateSecurityStampInternal(user).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + await store.SetPhoneNumberAsync(user, phoneNumber, CancellationToken).ConfigureAwait(false); + await store.SetPhoneNumberConfirmedAsync(user, false, CancellationToken).ConfigureAwait(false); + await UpdateSecurityStampInternal(user).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.SetPhoneNumber).ConfigureAwait(false); + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.SetPhoneNumber, ex); + throw; + } } /// @@ -1468,6 +1732,21 @@ public virtual async Task SetPhoneNumberAsync(TUser user, string /// of the operation. /// public virtual async Task ChangePhoneNumberAsync(TUser user, string phoneNumber, string token) + { + try + { + var result = await ChangePhoneNumberCoreAsync(user, phoneNumber, token).ConfigureAwait(false); + _metrics?.UpdateUser(typeof(TUser).FullName!, result, UserUpdateType.ChangePhoneNumber); + return result; + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.ChangePhoneNumber, ex); + throw; + } + } + + private async Task ChangePhoneNumberCoreAsync(TUser user, string phoneNumber, string token) { ThrowIfDisposed(); var store = GetPhoneNumberStore(); @@ -1510,7 +1789,6 @@ public virtual Task IsPhoneNumberConfirmedAsync(TUser user) /// public virtual Task GenerateChangePhoneNumberTokenAsync(TUser user, string phoneNumber) { - ThrowIfDisposed(); return GenerateUserTokenAsync(user, Options.Tokens.ChangePhoneNumberTokenProvider, ChangePhoneNumberTokenPurpose + ":" + phoneNumber); } @@ -1548,22 +1826,31 @@ public virtual Task VerifyChangePhoneNumberTokenAsync(TUser user, string t /// public virtual async Task VerifyUserTokenAsync(TUser user, string tokenProvider, string purpose, string token) { - ThrowIfDisposed(); - ArgumentNullThrowHelper.ThrowIfNull(user); - ArgumentNullThrowHelper.ThrowIfNull(tokenProvider); - - if (!_tokenProviders.TryGetValue(tokenProvider, out var provider)) + try { - throw new NotSupportedException(Resources.FormatNoTokenProvider(nameof(TUser), tokenProvider)); - } - // Make sure the token is valid - var result = await provider.ValidateAsync(purpose, token, this, user).ConfigureAwait(false); + ThrowIfDisposed(); + ArgumentNullThrowHelper.ThrowIfNull(user); + ArgumentNullThrowHelper.ThrowIfNull(tokenProvider); - if (!result && Logger.IsEnabled(LogLevel.Debug)) + if (!_tokenProviders.TryGetValue(tokenProvider, out var provider)) + { + throw new NotSupportedException(Resources.FormatNoTokenProvider(nameof(TUser), tokenProvider)); + } + // Make sure the token is valid + var result = await provider.ValidateAsync(purpose, token, this, user).ConfigureAwait(false); + _metrics?.VerifyToken(typeof(TUser).FullName!, result, purpose); + + if (!result && Logger.IsEnabled(LogLevel.Debug)) + { + Logger.LogDebug(LoggerEventIds.VerifyUserTokenFailed, "VerifyUserTokenAsync() failed with purpose: {purpose} for user.", purpose); + } + return result; + } + catch (Exception ex) { - Logger.LogDebug(LoggerEventIds.VerifyUserTokenFailed, "VerifyUserTokenAsync() failed with purpose: {purpose} for user.", purpose); + _metrics?.VerifyToken(typeof(TUser).FullName!, result: null, purpose, ex); + throw; } - return result; } /// @@ -1578,16 +1865,25 @@ public virtual async Task VerifyUserTokenAsync(TUser user, string tokenPro /// public virtual Task GenerateUserTokenAsync(TUser user, string tokenProvider, string purpose) { - ThrowIfDisposed(); - ArgumentNullThrowHelper.ThrowIfNull(user); - ArgumentNullThrowHelper.ThrowIfNull(tokenProvider); + try + { + ThrowIfDisposed(); + ArgumentNullThrowHelper.ThrowIfNull(user); + ArgumentNullThrowHelper.ThrowIfNull(tokenProvider); - if (!_tokenProviders.TryGetValue(tokenProvider, out var provider)) + if (!_tokenProviders.TryGetValue(tokenProvider, out var provider)) + { + throw new NotSupportedException(Resources.FormatNoTokenProvider(nameof(TUser), tokenProvider)); + } + + _metrics?.GenerateToken(typeof(TUser).FullName!, purpose); + return provider.GenerateAsync(purpose, this, user); + } + catch (Exception ex) { - throw new NotSupportedException(Resources.FormatNoTokenProvider(nameof(TUser), tokenProvider)); + _metrics?.GenerateToken(typeof(TUser).FullName!, purpose, ex); + throw; } - - return provider.GenerateAsync(purpose, this, user); } /// @@ -1638,20 +1934,30 @@ public virtual async Task> GetValidTwoFactorProvidersAsync(TUser u /// public virtual async Task VerifyTwoFactorTokenAsync(TUser user, string tokenProvider, string token) { - ThrowIfDisposed(); - ArgumentNullThrowHelper.ThrowIfNull(user); - if (!_tokenProviders.TryGetValue(tokenProvider, out var provider)) + try { - throw new NotSupportedException(Resources.FormatNoTokenProvider(nameof(TUser), tokenProvider)); - } + ThrowIfDisposed(); + ArgumentNullThrowHelper.ThrowIfNull(user); + if (!_tokenProviders.TryGetValue(tokenProvider, out var provider)) + { + throw new NotSupportedException(Resources.FormatNoTokenProvider(nameof(TUser), tokenProvider)); + } + + // Make sure the token is valid + var result = await provider.ValidateAsync("TwoFactor", token, this, user).ConfigureAwait(false); + _metrics?.VerifyToken(typeof(TUser).FullName!, result, "TwoFactor"); - // Make sure the token is valid - var result = await provider.ValidateAsync("TwoFactor", token, this, user).ConfigureAwait(false); - if (!result) + if (!result) + { + Logger.LogDebug(LoggerEventIds.VerifyTwoFactorTokenFailed, $"{nameof(VerifyTwoFactorTokenAsync)}() failed for user."); + } + return result; + } + catch (Exception ex) { - Logger.LogDebug(LoggerEventIds.VerifyTwoFactorTokenFailed, $"{nameof(VerifyTwoFactorTokenAsync)}() failed for user."); + _metrics?.VerifyToken(typeof(TUser).FullName!, result: null, "TwoFactor", ex); + throw; } - return result; } /// @@ -1665,14 +1971,23 @@ public virtual async Task VerifyTwoFactorTokenAsync(TUser user, string tok /// public virtual Task GenerateTwoFactorTokenAsync(TUser user, string tokenProvider) { - ThrowIfDisposed(); - ArgumentNullThrowHelper.ThrowIfNull(user); - if (!_tokenProviders.TryGetValue(tokenProvider, out var provider)) + try { - throw new NotSupportedException(Resources.FormatNoTokenProvider(nameof(TUser), tokenProvider)); - } + ThrowIfDisposed(); + ArgumentNullThrowHelper.ThrowIfNull(user); + if (!_tokenProviders.TryGetValue(tokenProvider, out var provider)) + { + throw new NotSupportedException(Resources.FormatNoTokenProvider(nameof(TUser), tokenProvider)); + } - return provider.GenerateAsync("TwoFactor", this, user); + _metrics?.GenerateToken(typeof(TUser).FullName!, "TwoFactor"); + return provider.GenerateAsync("TwoFactor", this, user); + } + catch (Exception ex) + { + _metrics?.GenerateToken(typeof(TUser).FullName!, "TwoFactor", ex); + throw; + } } /// @@ -1703,13 +2018,21 @@ public virtual async Task GetTwoFactorEnabledAsync(TUser user) /// public virtual async Task SetTwoFactorEnabledAsync(TUser user, bool enabled) { - ThrowIfDisposed(); - var store = GetUserTwoFactorStore(); - ArgumentNullThrowHelper.ThrowIfNull(user); + try + { + ThrowIfDisposed(); + var store = GetUserTwoFactorStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); - await store.SetTwoFactorEnabledAsync(user, enabled, CancellationToken).ConfigureAwait(false); - await UpdateSecurityStampInternal(user).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + await store.SetTwoFactorEnabledAsync(user, enabled, CancellationToken).ConfigureAwait(false); + await UpdateSecurityStampInternal(user).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.SetTwoFactorEnabled).ConfigureAwait(false); + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.SetTwoFactorEnabled, ex); + throw; + } } /// @@ -1745,12 +2068,20 @@ public virtual async Task IsLockedOutAsync(TUser user) /// public virtual async Task SetLockoutEnabledAsync(TUser user, bool enabled) { - ThrowIfDisposed(); - var store = GetUserLockoutStore(); - ArgumentNullThrowHelper.ThrowIfNull(user); + try + { + ThrowIfDisposed(); + var store = GetUserLockoutStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); - await store.SetLockoutEnabledAsync(user, enabled, CancellationToken).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + await store.SetLockoutEnabledAsync(user, enabled, CancellationToken).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.SetLockoutEnabled).ConfigureAwait(false); + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.SetLockoutEnabled, ex); + throw; + } } /// @@ -1791,6 +2122,21 @@ public virtual async Task GetLockoutEnabledAsync(TUser user) /// The after which the 's lockout should end. /// The that represents the asynchronous operation, containing the of the operation. public virtual async Task SetLockoutEndDateAsync(TUser user, DateTimeOffset? lockoutEnd) + { + try + { + var result = await SetLockoutEndDateCoreAsync(user, lockoutEnd).ConfigureAwait(false); + _metrics?.UpdateUser(typeof(TUser).FullName!, result, UserUpdateType.SetLockoutEndDate); + return result; + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.SetLockoutEndDate, ex); + throw; + } + } + + private async Task SetLockoutEndDateCoreAsync(TUser user, DateTimeOffset? lockoutEnd) { ThrowIfDisposed(); var store = GetUserLockoutStore(); @@ -1814,21 +2160,29 @@ public virtual async Task SetLockoutEndDateAsync(TUser user, Dat /// The that represents the asynchronous operation, containing the of the operation. public virtual async Task AccessFailedAsync(TUser user) { - ThrowIfDisposed(); - var store = GetUserLockoutStore(); - ArgumentNullThrowHelper.ThrowIfNull(user); + try + { + ThrowIfDisposed(); + var store = GetUserLockoutStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); - // If this puts the user over the threshold for lockout, lock them out and reset the access failed count - var count = await store.IncrementAccessFailedCountAsync(user, CancellationToken).ConfigureAwait(false); - if (count < Options.Lockout.MaxFailedAccessAttempts) + // If this puts the user over the threshold for lockout, lock them out and reset the access failed count + var count = await store.IncrementAccessFailedCountAsync(user, CancellationToken).ConfigureAwait(false); + if (count < Options.Lockout.MaxFailedAccessAttempts) + { + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.AccessFailed).ConfigureAwait(false); + } + Logger.LogDebug(LoggerEventIds.UserLockedOut, "User is locked out."); + await store.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.Add(Options.Lockout.DefaultLockoutTimeSpan), + CancellationToken).ConfigureAwait(false); + await store.ResetAccessFailedCountAsync(user, CancellationToken).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.AccessFailed).ConfigureAwait(false); + } + catch (Exception ex) { - return await UpdateUserAsync(user).ConfigureAwait(false); + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.AccessFailed, ex); + throw; } - Logger.LogDebug(LoggerEventIds.UserLockedOut, "User is locked out."); - await store.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.Add(Options.Lockout.DefaultLockoutTimeSpan), - CancellationToken).ConfigureAwait(false); - await store.ResetAccessFailedCountAsync(user, CancellationToken).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); } /// @@ -1837,6 +2191,21 @@ await store.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.Add(Options.Locko /// The user whose failed access count should be reset. /// The that represents the asynchronous operation, containing the of the operation. public virtual async Task ResetAccessFailedCountAsync(TUser user) + { + try + { + var result = await ResetAccessFailedCountCoreAsync(user).ConfigureAwait(false); + _metrics?.UpdateUser(typeof(TUser).FullName!, result, UserUpdateType.ResetAccessFailedCount); + return result; + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.ResetAccessFailedCount, ex); + throw; + } + } + + private async Task ResetAccessFailedCountCoreAsync(TUser user) { ThrowIfDisposed(); var store = GetUserLockoutStore(); @@ -1925,15 +2294,23 @@ public virtual Task> GetUsersInRoleAsync(string roleName) /// Whether the user was successfully updated. public virtual async Task SetAuthenticationTokenAsync(TUser user, string loginProvider, string tokenName, string? tokenValue) { - ThrowIfDisposed(); - var store = GetAuthenticationTokenStore(); - ArgumentNullThrowHelper.ThrowIfNull(user); - ArgumentNullThrowHelper.ThrowIfNull(loginProvider); - ArgumentNullThrowHelper.ThrowIfNull(tokenName); + try + { + ThrowIfDisposed(); + var store = GetAuthenticationTokenStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + ArgumentNullThrowHelper.ThrowIfNull(loginProvider); + ArgumentNullThrowHelper.ThrowIfNull(tokenName); - // REVIEW: should updating any tokens affect the security stamp? - await store.SetTokenAsync(user, loginProvider, tokenName, tokenValue, CancellationToken).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + // REVIEW: should updating any tokens affect the security stamp? + await store.SetTokenAsync(user, loginProvider, tokenName, tokenValue, CancellationToken).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.SetAuthenticationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.SetAuthenticationToken, ex); + throw; + } } /// @@ -1945,14 +2322,22 @@ public virtual async Task SetAuthenticationTokenAsync(TUser user /// Whether a token was removed. public virtual async Task RemoveAuthenticationTokenAsync(TUser user, string loginProvider, string tokenName) { - ThrowIfDisposed(); - var store = GetAuthenticationTokenStore(); - ArgumentNullThrowHelper.ThrowIfNull(user); - ArgumentNullThrowHelper.ThrowIfNull(loginProvider); - ArgumentNullThrowHelper.ThrowIfNull(tokenName); + try + { + ThrowIfDisposed(); + var store = GetAuthenticationTokenStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + ArgumentNullThrowHelper.ThrowIfNull(loginProvider); + ArgumentNullThrowHelper.ThrowIfNull(tokenName); - await store.RemoveTokenAsync(user, loginProvider, tokenName, CancellationToken).ConfigureAwait(false); - return await UpdateUserAsync(user).ConfigureAwait(false); + await store.RemoveTokenAsync(user, loginProvider, tokenName, CancellationToken).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.RemoveAuthenticationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.RemoveAuthenticationToken, ex); + throw; + } } /// @@ -1980,7 +2365,7 @@ public virtual async Task ResetAuthenticatorKeyAsync(TUser user) ArgumentNullThrowHelper.ThrowIfNull(user); await store.SetAuthenticatorKeyAsync(user, GenerateNewAuthenticatorKey(), CancellationToken).ConfigureAwait(false); await UpdateSecurityStampInternal(user).ConfigureAwait(false); - return await UpdateAsync(user).ConfigureAwait(false); + return await UpdateUserAndRecordMetricAsync(user, UserUpdateType.ResetAuthenticatorKey).ConfigureAwait(false); } /// @@ -1998,23 +2383,31 @@ public virtual string GenerateNewAuthenticatorKey() /// The new recovery codes for the user. Note: there may be less than number returned, as duplicates will be removed. public virtual async Task?> GenerateNewTwoFactorRecoveryCodesAsync(TUser user, int number) { - ThrowIfDisposed(); - var store = GetRecoveryCodeStore(); - ArgumentNullThrowHelper.ThrowIfNull(user); - - var newCodes = new List(number); - for (var i = 0; i < number; i++) + try { - newCodes.Add(CreateTwoFactorRecoveryCode()); - } + ThrowIfDisposed(); + var store = GetRecoveryCodeStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + + var newCodes = new List(number); + for (var i = 0; i < number; i++) + { + newCodes.Add(CreateTwoFactorRecoveryCode()); + } - await store.ReplaceCodesAsync(user, newCodes.Distinct(), CancellationToken).ConfigureAwait(false); - var update = await UpdateAsync(user).ConfigureAwait(false); - if (update.Succeeded) + await store.ReplaceCodesAsync(user, newCodes.Distinct(), CancellationToken).ConfigureAwait(false); + var update = await UpdateUserAndRecordMetricAsync(user, UserUpdateType.GenerateNewTwoFactorRecoveryCodes).ConfigureAwait(false); + if (update.Succeeded) + { + return newCodes; + } + return null; + } + catch (Exception ex) { - return newCodes; + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.GenerateNewTwoFactorRecoveryCodes, ex); + throw; } - return null; } /// @@ -2101,6 +2494,21 @@ private static char GetRandomRecoveryCodeChar() /// The recovery code to use. /// True if the recovery code was found for the user. public virtual async Task RedeemTwoFactorRecoveryCodeAsync(TUser user, string code) + { + try + { + var result = await RedeemTwoFactorRecoveryCodeCoreAsync(user, code).ConfigureAwait(false); + _metrics?.UpdateUser(typeof(TUser).FullName!, result, UserUpdateType.RedeemTwoFactorRecoveryCode); + return result; + } + catch (Exception ex) + { + _metrics?.UpdateUser(typeof(TUser).FullName!, result: null, UserUpdateType.RedeemTwoFactorRecoveryCode, ex); + throw; + } + } + + private async Task RedeemTwoFactorRecoveryCodeCoreAsync(TUser user, string code) { ThrowIfDisposed(); var store = GetRecoveryCodeStore(); @@ -2109,7 +2517,7 @@ public virtual async Task RedeemTwoFactorRecoveryCodeAsync(TUser var success = await store.RedeemCodeAsync(user, code, CancellationToken).ConfigureAwait(false); if (success) { - return await UpdateAsync(user).ConfigureAwait(false); + return await UpdateUserAsync(user).ConfigureAwait(false); } return IdentityResult.Failed(ErrorDescriber.RecoveryCodeRedemptionFailed()); } @@ -2298,6 +2706,8 @@ private IUserClaimStore GetClaimStore() /// A representing whether validation was successful. protected async Task ValidateUserAsync(TUser user) { + // NOTE: Metrics aren't recorded here. Instead, the result is used in other methods. + if (SupportsUserSecurityStamp) { var stamp = await GetSecurityStampAsync(user).ConfigureAwait(false); @@ -2370,6 +2780,8 @@ protected async Task ValidatePasswordAsync(TUser user, string? p /// Whether the operation was successful. protected virtual async Task UpdateUserAsync(TUser user) { + // NOTE: Metrics aren't recorded here. Instead, the result is used in other methods. + var result = await ValidateUserAsync(user).ConfigureAwait(false); if (!result.Succeeded) { @@ -2380,6 +2792,14 @@ protected virtual async Task UpdateUserAsync(TUser user) return await Store.UpdateAsync(user, CancellationToken).ConfigureAwait(false); } + private async Task UpdateUserAndRecordMetricAsync(TUser user, UserUpdateType updateType) + { + var result = await UpdateUserAsync(user).ConfigureAwait(false); + _metrics?.UpdateUser(typeof(TUser).FullName!, result, updateType); + + return result; + } + private IUserAuthenticatorKeyStore GetAuthenticatorKeyStore() { var cast = Store as IUserAuthenticatorKeyStore; diff --git a/src/Identity/Extensions.Core/src/UserManagerMetrics.cs b/src/Identity/Extensions.Core/src/UserManagerMetrics.cs new file mode 100644 index 000000000000..6e25c86db986 --- /dev/null +++ b/src/Identity/Extensions.Core/src/UserManagerMetrics.cs @@ -0,0 +1,284 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Threading.Tasks; +using static Microsoft.AspNetCore.Identity.UserManagerMetrics; + +namespace Microsoft.AspNetCore.Identity; + +internal sealed class UserManagerMetrics : IDisposable +{ + public const string MeterName = "Microsoft.AspNetCore.Identity"; + + public const string CreateCounterName = "aspnetcore.identity.user.create"; + public const string UpdateCounterName = "aspnetcore.identity.user.update"; + public const string DeleteCounterName = "aspnetcore.identity.user.delete"; + public const string CheckPasswordCounterName = "aspnetcore.identity.user.check_password"; + public const string VerifyPasswordCounterName = "aspnetcore.identity.user.verify_password"; + public const string VerifyTokenCounterName = "aspnetcore.identity.user.verify_token"; + public const string GenerateTokenCounterName = "aspnetcore.identity.user.generate_token"; + + private readonly Meter _meter; + private readonly Counter _createCounter; + private readonly Counter _updateCounter; + private readonly Counter _deleteCounter; + private readonly Counter _checkPasswordCounter; + private readonly Counter _verifyPasswordCounter; + private readonly Counter _verifyTokenCounter; + private readonly Counter _generateTokenCounter; + + public UserManagerMetrics(IMeterFactory meterFactory) + { + _meter = meterFactory.Create(MeterName); + _createCounter = _meter.CreateCounter(CreateCounterName, "count", "The number of users created."); + _updateCounter = _meter.CreateCounter(UpdateCounterName, "count", "The number of user updates."); + _deleteCounter = _meter.CreateCounter(DeleteCounterName, "count", "The number of users deleted."); + _checkPasswordCounter = _meter.CreateCounter(CheckPasswordCounterName, "count", "The number of check password attempts."); + _verifyPasswordCounter = _meter.CreateCounter(VerifyPasswordCounterName, "count", "The number of password verification attempts."); + _verifyTokenCounter = _meter.CreateCounter(VerifyTokenCounterName, "count", "The number of token verification attempts."); + _generateTokenCounter = _meter.CreateCounter(GenerateTokenCounterName, "count", "The number of token generation attempts."); + } + + internal void CreateUser(string userType, IdentityResult? result, Exception? exception = null) + { + if (_createCounter.Enabled) + { + var tags = new TagList + { + { "aspnetcore.identity.user_type", userType } + }; + AddIdentityResultTags(ref tags, result); + AddExceptionTags(ref tags, exception); + + _createCounter.Add(1, tags); + } + } + + private static void AddExceptionTags(ref TagList tags, Exception? exception) + { + if (exception != null) + { + tags.Add("error.type", exception.GetType().FullName!); + } + } + + internal void UpdateUser(string userType, IdentityResult? result, UserUpdateType updateType, Exception? exception = null) + { + if (_updateCounter.Enabled) + { + var tags = new TagList + { + { "aspnetcore.identity.user_type", userType }, + { "aspnetcore.identity.user.update_type", GetUpdateType(updateType) }, + }; + AddIdentityResultTags(ref tags, result); + AddExceptionTags(ref tags, exception); + + _updateCounter.Add(1, tags); + } + } + + internal void DeleteUser(string userType, IdentityResult? result, Exception? exception = null) + { + if (_deleteCounter.Enabled) + { + var tags = new TagList + { + { "aspnetcore.identity.user_type", userType } + }; + AddIdentityResultTags(ref tags, result); + AddExceptionTags(ref tags, exception); + + _deleteCounter.Add(1, tags); + } + } + + internal void CheckPassword(string userType, bool? userMissing, PasswordVerificationResult? result, Exception? exception = null) + { + if (_checkPasswordCounter.Enabled) + { + var tags = new TagList + { + { "aspnetcore.identity.user_type", userType }, + }; + if (userMissing != null || result != null) + { + tags.Add("aspnetcore.identity.user.password_result", GetPasswordResult(result, passwordMissing: null, userMissing)); + } + AddExceptionTags(ref tags, exception); + + _checkPasswordCounter.Add(1, tags); + } + } + + internal void VerifyPassword(string userType, bool passwordMissing, PasswordVerificationResult? result) + { + if (_verifyPasswordCounter.Enabled) + { + var tags = new TagList + { + { "aspnetcore.identity.user_type", userType }, + { "aspnetcore.identity.user.password_result", GetPasswordResult(result, passwordMissing, userMissing: null) }, + }; + + _verifyPasswordCounter.Add(1, tags); + } + } + + internal void VerifyToken(string userType, bool? result, string purpose, Exception? exception = null) + { + var tags = new TagList + { + { "aspnetcore.identity.user_type", userType }, + { "aspnetcore.identity.token_purpose", GetTokenPurpose(purpose) }, + }; + if (result != null) + { + tags.Add("aspnetcore.identity.token_verified", result == true ? "success" : "failure"); + } + AddExceptionTags(ref tags, exception); + + _verifyTokenCounter.Add(1, tags); + } + + internal void GenerateToken(string userType, string purpose, Exception? exception = null) + { + var tags = new TagList + { + { "aspnetcore.identity.user_type", userType }, + { "aspnetcore.identity.token_purpose", GetTokenPurpose(purpose) }, + }; + AddExceptionTags(ref tags, exception); + + _generateTokenCounter.Add(1, tags); + } + + private static string GetTokenPurpose(string purpose) + { + // Purpose could be any value and can't be used as a tag value. However, there are known purposes + // on UserManager that we can detect and use as a tag value. Some could have a ':' in them followed by user data. + // We need to trim them to content before ':' and then match to known values. + var trimmedPurpose = purpose; + var colonIndex = purpose.IndexOf(':'); + if (colonIndex >= 0) + { + trimmedPurpose = purpose.Substring(0, colonIndex); + } + + return trimmedPurpose switch + { + "ResetPassword" => "reset_password", + "ChangePhoneNumber" => "change_phone_number", + "EmailConfirmation" => "email_confirmation", + "ChangeEmail" => "change_email", + "TwoFactor" => "two_factor", + _ => "_UNKNOWN" + }; + } + + private static void AddIdentityResultTags(ref TagList tags, IdentityResult? result) + { + if (result == null) + { + return; + } + + tags.Add("aspnetcore.identity.result", result.Succeeded ? "success" : "failure"); + if (!result.Succeeded && result.Errors.FirstOrDefault()?.Code is { Length: > 0 } code) + { + tags.Add("aspnetcore.identity.result_error_code", code); + } + } + + private static string GetPasswordResult(PasswordVerificationResult? result, bool? passwordMissing, bool? userMissing) + { + return (result, passwordMissing ?? false, userMissing ?? false) switch + { + (PasswordVerificationResult.Success, false, false) => "success", + (PasswordVerificationResult.SuccessRehashNeeded, false, false) => "success_rehash_needed", + (PasswordVerificationResult.Failed, false, false) => "failure", + (null, true, false) => "password_missing", + (null, false, true) => "user_missing", + _ => "_UNKNOWN" + }; + } + + private static string GetUpdateType(UserUpdateType updateType) + { + return updateType switch + { + UserUpdateType.Update => "update", + UserUpdateType.UserName => "user_name", + UserUpdateType.AddPassword => "add_password", + UserUpdateType.ChangePassword => "change_password", + UserUpdateType.SecurityStamp => "security_stamp", + UserUpdateType.ResetPassword => "reset_password", + UserUpdateType.RemoveLogin => "remove_login", + UserUpdateType.AddLogin => "add_login", + UserUpdateType.AddClaims => "add_claims", + UserUpdateType.ReplaceClaim => "replace_claim", + UserUpdateType.RemoveClaims => "remove_claims", + UserUpdateType.AddToRoles => "add_to_roles", + UserUpdateType.RemoveFromRoles => "remove_from_roles", + UserUpdateType.SetEmail => "set_email", + UserUpdateType.ConfirmEmail => "confirm_email", + UserUpdateType.PasswordRehash => "password_rehash", + UserUpdateType.RemovePassword => "remove_password", + UserUpdateType.ChangeEmail => "change_email", + UserUpdateType.SetPhoneNumber => "set_phone_number", + UserUpdateType.ChangePhoneNumber => "change_phone_number", + UserUpdateType.SetTwoFactorEnabled => "set_two_factor_enabled", + UserUpdateType.SetLockoutEnabled => "set_lockout_enabled", + UserUpdateType.SetLockoutEndDate => "set_lockout_end_date", + UserUpdateType.AccessFailed => "access_failed", + UserUpdateType.ResetAccessFailedCount => "reset_access_failed_count", + UserUpdateType.SetAuthenticationToken => "set_authentication_token", + UserUpdateType.RemoveAuthenticationToken => "remove_authentication_token", + UserUpdateType.ResetAuthenticatorKey => "reset_authenticator_key", + _ => "_UNKNOWN" + }; + } + + public void Dispose() + { + _meter.Dispose(); + } +} + +internal enum UserUpdateType +{ + Update, + UserName, + AddPassword, + ChangePassword, + SecurityStamp, + ResetPassword, + RemoveLogin, + AddLogin, + AddClaims, + ReplaceClaim, + RemoveClaims, + AddToRoles, + RemoveFromRoles, + SetEmail, + ConfirmEmail, + PasswordRehash, + RemovePassword, + ChangeEmail, + SetPhoneNumber, + ChangePhoneNumber, + SetTwoFactorEnabled, + SetLockoutEnabled, + SetLockoutEndDate, + AccessFailed, + ResetAccessFailedCount, + SetAuthenticationToken, + RemoveAuthenticationToken, + ResetAuthenticatorKey, + GenerateNewTwoFactorRecoveryCodes, + RedeemTwoFactorRecoveryCode, +} diff --git a/src/Identity/Identity.slnf b/src/Identity/Identity.slnf index 8e6b1617cb3d..1a8aba8f8489 100644 --- a/src/Identity/Identity.slnf +++ b/src/Identity/Identity.slnf @@ -10,6 +10,7 @@ "src\\DataProtection\\Cryptography.KeyDerivation\\src\\Microsoft.AspNetCore.Cryptography.KeyDerivation.csproj", "src\\DataProtection\\DataProtection\\src\\Microsoft.AspNetCore.DataProtection.csproj", "src\\DataProtection\\Extensions\\src\\Microsoft.AspNetCore.DataProtection.Extensions.csproj", + "src\\DefaultBuilder\\src\\Microsoft.AspNetCore.csproj", "src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj", "src\\Features\\JsonPatch\\src\\Microsoft.AspNetCore.JsonPatch.csproj", "src\\Hosting\\Abstractions\\src\\Microsoft.AspNetCore.Hosting.Abstractions.csproj", @@ -50,6 +51,7 @@ "src\\Middleware\\Diagnostics.Abstractions\\src\\Microsoft.AspNetCore.Diagnostics.Abstractions.csproj", "src\\Middleware\\Diagnostics.EntityFrameworkCore\\src\\Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore.csproj", "src\\Middleware\\Diagnostics\\src\\Microsoft.AspNetCore.Diagnostics.csproj", + "src\\Middleware\\HostFiltering\\src\\Microsoft.AspNetCore.HostFiltering.csproj", "src\\Middleware\\HttpOverrides\\src\\Microsoft.AspNetCore.HttpOverrides.csproj", "src\\Middleware\\HttpsPolicy\\src\\Microsoft.AspNetCore.HttpsPolicy.csproj", "src\\Middleware\\Localization\\src\\Microsoft.AspNetCore.Localization.csproj", @@ -74,19 +76,25 @@ "src\\Razor\\Razor.Runtime\\src\\Microsoft.AspNetCore.Razor.Runtime.csproj", "src\\Razor\\Razor\\src\\Microsoft.AspNetCore.Razor.csproj", "src\\Security\\Authentication\\BearerToken\\src\\Microsoft.AspNetCore.Authentication.BearerToken.csproj", + "src\\Security\\Authentication\\Certificate\\src\\Microsoft.AspNetCore.Authentication.Certificate.csproj", "src\\Security\\Authentication\\Cookies\\src\\Microsoft.AspNetCore.Authentication.Cookies.csproj", "src\\Security\\Authentication\\Core\\src\\Microsoft.AspNetCore.Authentication.csproj", "src\\Security\\Authentication\\Facebook\\src\\Microsoft.AspNetCore.Authentication.Facebook.csproj", "src\\Security\\Authentication\\Google\\src\\Microsoft.AspNetCore.Authentication.Google.csproj", "src\\Security\\Authentication\\JwtBearer\\src\\Microsoft.AspNetCore.Authentication.JwtBearer.csproj", + "src\\Security\\Authentication\\MicrosoftAccount\\src\\Microsoft.AspNetCore.Authentication.MicrosoftAccount.csproj", "src\\Security\\Authentication\\OAuth\\src\\Microsoft.AspNetCore.Authentication.OAuth.csproj", + "src\\Security\\Authentication\\OpenIdConnect\\src\\Microsoft.AspNetCore.Authentication.OpenIdConnect.csproj", "src\\Security\\Authentication\\Twitter\\src\\Microsoft.AspNetCore.Authentication.Twitter.csproj", + "src\\Security\\Authentication\\WsFederation\\src\\Microsoft.AspNetCore.Authentication.WsFederation.csproj", "src\\Security\\Authentication\\test\\Microsoft.AspNetCore.Authentication.Test.csproj", "src\\Security\\Authorization\\Core\\src\\Microsoft.AspNetCore.Authorization.csproj", "src\\Security\\Authorization\\Policy\\src\\Microsoft.AspNetCore.Authorization.Policy.csproj", "src\\Security\\CookiePolicy\\src\\Microsoft.AspNetCore.CookiePolicy.csproj", "src\\Servers\\Connections.Abstractions\\src\\Microsoft.AspNetCore.Connections.Abstractions.csproj", + "src\\Servers\\HttpSys\\src\\Microsoft.AspNetCore.Server.HttpSys.csproj", "src\\Servers\\IIS\\IISIntegration\\src\\Microsoft.AspNetCore.Server.IISIntegration.csproj", + "src\\Servers\\IIS\\IIS\\src\\Microsoft.AspNetCore.Server.IIS.csproj", "src\\Servers\\Kestrel\\Core\\src\\Microsoft.AspNetCore.Server.Kestrel.Core.csproj", "src\\Servers\\Kestrel\\Kestrel\\src\\Microsoft.AspNetCore.Server.Kestrel.csproj", "src\\Servers\\Kestrel\\Transport.NamedPipes\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.csproj", @@ -96,4 +104,4 @@ "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj" ] } -} +} \ No newline at end of file diff --git a/src/Identity/test/Identity.Test/Microsoft.AspNetCore.Identity.Test.csproj b/src/Identity/test/Identity.Test/Microsoft.AspNetCore.Identity.Test.csproj index 798e4dc3db40..456637020d55 100644 --- a/src/Identity/test/Identity.Test/Microsoft.AspNetCore.Identity.Test.csproj +++ b/src/Identity/test/Identity.Test/Microsoft.AspNetCore.Identity.Test.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework) @@ -6,6 +6,7 @@ + @@ -16,6 +17,7 @@ + diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index 73fe6d6be218..866ad8640da3 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -1,11 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; +using System.Diagnostics.Metrics; using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.Metrics.Testing; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -31,8 +35,12 @@ public void ConstructorNullChecks() public async Task PasswordSignInReturnsLockedOutWhenLockedOut() { // Setup + var testMeterFactory = new TestMeterFactory(); + using var authenticate = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", SignInManagerMetrics.AuthenticateCounterName); + using var signInUserPrincipal = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", SignInManagerMetrics.SignInUserPrincipalCounterName); + var user = new PocoUser { UserName = "Foo" }; - var manager = SetupUserManager(user); + var manager = SetupUserManager(user, meterFactory: testMeterFactory); manager.Setup(m => m.SupportsUserLockout).Returns(true).Verifiable(); manager.Setup(m => m.IsLockedOutAsync(user)).ReturnsAsync(true).Verifiable(); @@ -55,14 +63,28 @@ public async Task PasswordSignInReturnsLockedOutWhenLockedOut() Assert.True(result.IsLockedOut); Assert.Contains($"User is currently locked out.", logger.LogMessages); manager.Verify(); + + Assert.Collection(authenticate.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.authentication_scheme", "Identity.Application"), + KeyValuePair.Create("aspnetcore.identity.sign_in.type", "password"), + KeyValuePair.Create("aspnetcore.identity.sign_in.is_persistent", false), + KeyValuePair.Create("aspnetcore.identity.sign_in.result", "locked_out"), + ])); + Assert.Empty(signInUserPrincipal.GetMeasurementSnapshot()); } [Fact] public async Task CheckPasswordSignInReturnsLockedOutWhenLockedOut() { // Setup + var testMeterFactory = new TestMeterFactory(); + using var checkPasswordSignIn = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", SignInManagerMetrics.CheckPasswordCounterName); + var user = new PocoUser { UserName = "Foo" }; - var manager = SetupUserManager(user); + var manager = SetupUserManager(user, meterFactory: testMeterFactory); manager.Setup(m => m.SupportsUserLockout).Returns(true).Verifiable(); manager.Setup(m => m.IsLockedOutAsync(user)).ReturnsAsync(true).Verifiable(); @@ -85,11 +107,18 @@ public async Task CheckPasswordSignInReturnsLockedOutWhenLockedOut() Assert.True(result.IsLockedOut); Assert.Contains($"User is currently locked out.", logger.LogMessages); manager.Verify(); + + Assert.Collection(checkPasswordSignIn.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.sign_in.result", "locked_out"), + ])); } - private static Mock> SetupUserManager(PocoUser user) + private static Mock> SetupUserManager(PocoUser user, IMeterFactory meterFactory = null) { - var manager = MockHelpers.MockUserManager(); + var manager = MockHelpers.MockUserManager(meterFactory); manager.Setup(m => m.FindByNameAsync(user.UserName)).ReturnsAsync(user); manager.Setup(m => m.FindByIdAsync(user.Id)).ReturnsAsync(user); manager.Setup(m => m.GetUserIdAsync(user)).ReturnsAsync(user.Id.ToString()); @@ -300,10 +329,14 @@ public async Task PasswordSignInRequiresVerification(bool supportsLockout) public async Task ExternalSignInRequiresVerificationIfNotBypassed(bool bypass) { // Setup + var testMeterFactory = new TestMeterFactory(); + using var authenticate = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", SignInManagerMetrics.AuthenticateCounterName); + using var signInUserPrincipal = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", SignInManagerMetrics.SignInUserPrincipalCounterName); + var user = new PocoUser { UserName = "Foo" }; const string loginProvider = "login"; const string providerKey = "fookey"; - var manager = SetupUserManager(user); + var manager = SetupUserManager(user, meterFactory: testMeterFactory); manager.Setup(m => m.SupportsUserLockout).Returns(false).Verifiable(); manager.Setup(m => m.FindByLoginAsync(loginProvider, providerKey)).ReturnsAsync(user).Verifiable(); if (!bypass) @@ -337,6 +370,38 @@ public async Task ExternalSignInRequiresVerificationIfNotBypassed(bool bypass) Assert.Equal(!bypass, result.RequiresTwoFactor); manager.Verify(); auth.Verify(); + + if (bypass) + { + Assert.Collection(authenticate.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.authentication_scheme", "Identity.Application"), + KeyValuePair.Create("aspnetcore.identity.sign_in.type", "external"), + KeyValuePair.Create("aspnetcore.identity.sign_in.is_persistent", false), + KeyValuePair.Create("aspnetcore.identity.sign_in.result", "success"), + ])); + Assert.Collection(signInUserPrincipal.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.authentication_scheme", "Identity.Application"), + ])); + } + else + { + Assert.Collection(authenticate.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.authentication_scheme", "Identity.Application"), + KeyValuePair.Create("aspnetcore.identity.sign_in.type", "external"), + KeyValuePair.Create("aspnetcore.identity.sign_in.is_persistent", false), + KeyValuePair.Create("aspnetcore.identity.sign_in.result", "requires_two_factor"), + ])); + Assert.Empty(signInUserPrincipal.GetMeasurementSnapshot()); + } } private class GoodTokenProvider : AuthenticatorTokenProvider @@ -587,6 +652,38 @@ public async Task CanExternalSignIn(bool isPersistent, bool supportsLockout) auth.Verify(); } + [Fact] + public async Task SignInAsync_Failure() + { + // Setup + var testMeterFactory = new TestMeterFactory(); + using var signInUserPrincipal = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", SignInManagerMetrics.SignInUserPrincipalCounterName); + + var user = new PocoUser { UserName = "Foo" }; + var manager = SetupUserManager(user, meterFactory: testMeterFactory); + + var context = new DefaultHttpContext(); + var auth = MockAuth(context); + var helper = SetupSignInManager(manager.Object, context); + auth.Setup(a => a.SignInAsync(context, IdentityConstants.ApplicationScheme, It.IsAny(), It.IsAny())) + .Throws(new InvalidOperationException("SignInAsync failed")).Verifiable(); + + // Act + await Assert.ThrowsAsync(() => helper.SignInAsync(user, isPersistent: false)); + + // Assert + manager.Verify(); + auth.Verify(); + + Assert.Collection(signInUserPrincipal.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.authentication_scheme", "Identity.Application"), + KeyValuePair.Create("error.type", "System.InvalidOperationException"), + ])); + } + [Theory] [InlineData(true, true)] [InlineData(true, false)] @@ -595,6 +692,9 @@ public async Task CanExternalSignIn(bool isPersistent, bool supportsLockout) public async Task CanResignIn(bool isPersistent, bool externalLogin) { // Setup + var testMeterFactory = new TestMeterFactory(); + using var refreshSignIn = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", SignInManagerMetrics.RefreshCounterName); + var user = new PocoUser { UserName = "Foo" }; var context = new DefaultHttpContext(); var auth = MockAuth(context); @@ -610,7 +710,7 @@ public async Task CanResignIn(bool isPersistent, bool externalLogin) var authResult = AuthenticateResult.Success(new AuthenticationTicket(claimsPrincipal, properties, "authscheme")); auth.Setup(a => a.AuthenticateAsync(context, IdentityConstants.ApplicationScheme)) .Returns(Task.FromResult(authResult)).Verifiable(); - var manager = SetupUserManager(user); + var manager = SetupUserManager(user, meterFactory: testMeterFactory); manager.Setup(m => m.GetUserId(claimsPrincipal)).Returns(user.Id.ToString()); var signInManager = new Mock>(manager.Object, new HttpContextAccessor { HttpContext = context }, @@ -632,15 +732,28 @@ public async Task CanResignIn(bool isPersistent, bool externalLogin) // Assert auth.Verify(); signInManager.Verify(); + + Assert.Collection(refreshSignIn.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.authentication_scheme", "Identity.Application"), + KeyValuePair.Create("aspnetcore.identity.sign_in.is_persistent", isPersistent), + KeyValuePair.Create("aspnetcore.identity.sign_in.result", "success"), + ])); } [Fact] public async Task ResignInNoOpsAndLogsErrorIfNotAuthenticated() { + var testMeterFactory = new TestMeterFactory(); + using var refresh = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", SignInManagerMetrics.RefreshCounterName); + using var signInUserPrincipal = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", SignInManagerMetrics.SignInUserPrincipalCounterName); + var user = new PocoUser { UserName = "Foo" }; var context = new DefaultHttpContext(); var auth = MockAuth(context); - var manager = SetupUserManager(user); + var manager = SetupUserManager(user, meterFactory: testMeterFactory); var logger = new TestLogger>(); var signInManager = new Mock>(manager.Object, new HttpContextAccessor { HttpContext = context }, @@ -656,6 +769,15 @@ public async Task ResignInNoOpsAndLogsErrorIfNotAuthenticated() auth.Verify(); signInManager.Verify(s => s.SignInWithClaimsAsync(It.IsAny(), It.IsAny(), It.IsAny>()), Times.Never()); + + Assert.Collection(refresh.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.authentication_scheme", "Identity.Application"), + KeyValuePair.Create("aspnetcore.identity.sign_in.result", "failure"), + ])); + Assert.Empty(signInUserPrincipal.GetMeasurementSnapshot()); } [Fact] @@ -800,8 +922,11 @@ public async Task TwoFactorSignInAsyncReturnsLockedOut() public async Task RememberClientStoresUserId() { // Setup + var testMeterFactory = new TestMeterFactory(); + using var rememberTwoFactorClient = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", SignInManagerMetrics.RememberTwoFactorCounterName); + var user = new PocoUser { UserName = "Foo" }; - var manager = SetupUserManager(user); + var manager = SetupUserManager(user, meterFactory: testMeterFactory); var context = new DefaultHttpContext(); var auth = MockAuth(context); var helper = SetupSignInManager(manager.Object, context); @@ -818,6 +943,46 @@ public async Task RememberClientStoresUserId() // Assert manager.Verify(); auth.Verify(); + + Assert.Collection(rememberTwoFactorClient.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.authentication_scheme", "Identity.TwoFactorRememberMe"), + ])); + } + + [Fact] + public async Task ForgetTwoFactorClient() + { + // Setup + var testMeterFactory = new TestMeterFactory(); + using var forgetTwoFactorClient = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", SignInManagerMetrics.ForgetTwoFactorCounterName); + + var user = new PocoUser { UserName = "Foo" }; + var manager = SetupUserManager(user, meterFactory: testMeterFactory); + var context = new DefaultHttpContext(); + var auth = MockAuth(context); + var helper = SetupSignInManager(manager.Object, context); + auth.Setup(a => a.SignOutAsync( + context, + IdentityConstants.TwoFactorRememberMeScheme, + It.IsAny())).Returns(Task.FromException(new InvalidOperationException())).Verifiable(); + + // Act + await Assert.ThrowsAsync(() => helper.ForgetTwoFactorClientAsync()); + + // Assert + manager.Verify(); + auth.Verify(); + + Assert.Collection(forgetTwoFactorClient.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.authentication_scheme", "Identity.TwoFactorRememberMe"), + KeyValuePair.Create("error.type", "System.InvalidOperationException"), + ])); } [Theory] @@ -862,10 +1027,13 @@ private Mock MockAuth(HttpContext context) } [Fact] - public async Task SignOutCallsContextResponseSignOut() + public async Task SignOutCallsContextResponseSignOut_Success() { // Setup - var manager = MockHelpers.TestUserManager(); + var testMeterFactory = new TestMeterFactory(); + using var signOutUserPrincipal = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", SignInManagerMetrics.SignOutUserPrincipalCounterName); + + var manager = MockHelpers.TestUserManager(meterFactory: testMeterFactory); var context = new DefaultHttpContext(); var auth = MockAuth(context); auth.Setup(a => a.SignOutAsync(context, IdentityConstants.ApplicationScheme, It.IsAny())).Returns(Task.FromResult(0)).Verifiable(); @@ -878,14 +1046,50 @@ public async Task SignOutCallsContextResponseSignOut() // Assert auth.Verify(); + + Assert.Collection(signOutUserPrincipal.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.authentication_scheme", "Identity.Application"), + ])); + } + + [Fact] + public async Task SignOutCallsContextResponseSignOut_Failure() + { + // Setup + var testMeterFactory = new TestMeterFactory(); + using var signOutUserPrincipal = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", SignInManagerMetrics.SignOutUserPrincipalCounterName); + + var manager = MockHelpers.TestUserManager(meterFactory: testMeterFactory); + var context = new DefaultHttpContext(); + var auth = MockAuth(context); + auth.Setup(a => a.SignOutAsync(context, IdentityConstants.ApplicationScheme, It.IsAny())).Returns(Task.FromException(new InvalidOperationException("error!"))).Verifiable(); + var helper = SetupSignInManager(manager, context, null, manager.Options); + + // Act + await Assert.ThrowsAsync(() => helper.SignOutAsync()); + + // Assert + Assert.Collection(signOutUserPrincipal.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.authentication_scheme", "Identity.Application"), + KeyValuePair.Create("error.type", "System.InvalidOperationException"), + ])); } [Fact] public async Task PasswordSignInFailsWithWrongPassword() { // Setup + var testMeterFactory = new TestMeterFactory(); + using var authenticate = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", SignInManagerMetrics.AuthenticateCounterName); + var user = new PocoUser { UserName = "Foo" }; - var manager = SetupUserManager(user); + var manager = SetupUserManager(user, meterFactory: testMeterFactory); manager.Setup(m => m.SupportsUserLockout).Returns(true).Verifiable(); manager.Setup(m => m.IsLockedOutAsync(user)).ReturnsAsync(false).Verifiable(); manager.Setup(m => m.CheckPasswordAsync(user, "[PLACEHOLDER]-bogus1")).ReturnsAsync(false).Verifiable(); @@ -903,6 +1107,16 @@ public async Task PasswordSignInFailsWithWrongPassword() Assert.Contains($"User failed to provide the correct password.", logger.LogMessages); manager.Verify(); context.Verify(); + + Assert.Collection(authenticate.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.authentication_scheme", "Identity.Application"), + KeyValuePair.Create("aspnetcore.identity.sign_in.is_persistent", false), + KeyValuePair.Create("aspnetcore.identity.sign_in.result", "failure"), + KeyValuePair.Create("aspnetcore.identity.sign_in.type", "password"), + ])); } [Fact] diff --git a/src/Identity/test/Identity.Test/UserManagerTest.cs b/src/Identity/test/Identity.Test/UserManagerTest.cs index 04f9e2afa476..6ac2cf5480a8 100644 --- a/src/Identity/test/Identity.Test/UserManagerTest.cs +++ b/src/Identity/test/Identity.Test/UserManagerTest.cs @@ -1,10 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Globalization; using System.Security.Claims; +using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.Metrics.Testing; using Moq; namespace Microsoft.AspNetCore.Identity.Test; @@ -121,13 +124,16 @@ public async Task CreateCallsUpdateEmailStore() } [Fact] - public async Task DeleteCallsStore() + public async Task DeleteCallsStore_Success() { // Setup + var testMeterFactory = new TestMeterFactory(); + using var deleteUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.DeleteCounterName); + var store = new Mock>(); var user = new PocoUser { UserName = "Foo" }; store.Setup(s => s.DeleteAsync(user, CancellationToken.None)).ReturnsAsync(IdentityResult.Success).Verifiable(); - var userManager = MockHelpers.TestUserManager(store.Object); + var userManager = MockHelpers.TestUserManager(store.Object, meterFactory: testMeterFactory); // Act var result = await userManager.DeleteAsync(user); @@ -135,6 +141,41 @@ public async Task DeleteCallsStore() // Assert Assert.True(result.Succeeded); store.VerifyAll(); + + Assert.Collection(deleteUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.result", "success") + ])); + } + + [Fact] + public async Task DeleteCallsStore_Failure() + { + // Setup + var testMeterFactory = new TestMeterFactory(); + using var deleteUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.DeleteCounterName); + + var store = new Mock>(); + var user = new PocoUser { UserName = "Foo" }; + store.Setup(s => s.DeleteAsync(user, CancellationToken.None)).ReturnsAsync(IdentityResult.Failed(new IdentityErrorDescriber().ConcurrencyFailure())).Verifiable(); + var userManager = MockHelpers.TestUserManager(store.Object, meterFactory: testMeterFactory); + + // Act + var result = await userManager.DeleteAsync(user); + + // Assert + Assert.False(result.Succeeded); + store.VerifyAll(); + + Assert.Collection(deleteUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("aspnetcore.identity.result", "failure"), + KeyValuePair.Create("aspnetcore.identity.result_error_code", "ConcurrencyFailure") + ])); } [Fact] @@ -522,6 +563,9 @@ public async Task AddClaimsCallsStore() public async Task AddClaimCallsStore() { // Setup + var testMeterFactory = new TestMeterFactory(); + using var updateUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.UpdateCounterName); + var store = new Mock>(); var user = new PocoUser { UserName = "Foo" }; var claim = new Claim("1", "1"); @@ -529,7 +573,7 @@ public async Task AddClaimCallsStore() .Returns(Task.FromResult(0)) .Verifiable(); store.Setup(s => s.UpdateAsync(user, CancellationToken.None)).ReturnsAsync(IdentityResult.Success).Verifiable(); - var userManager = MockHelpers.TestUserManager(store.Object); + var userManager = MockHelpers.TestUserManager(store.Object, meterFactory: testMeterFactory); // Act var result = await userManager.AddClaimAsync(user, claim); @@ -537,12 +581,22 @@ public async Task AddClaimCallsStore() // Assert Assert.True(result.Succeeded); store.VerifyAll(); + + Assert.Collection(updateUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "add_claims"), + KeyValuePair.Create("aspnetcore.identity.result", "success") + ])); } [Fact] public async Task UpdateClaimCallsStore() { // Setup + var testMeterFactory = new TestMeterFactory(); + using var updateUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.UpdateCounterName); + var store = new Mock>(); var user = new PocoUser { UserName = "Foo" }; var claim = new Claim("1", "1"); @@ -551,7 +605,7 @@ public async Task UpdateClaimCallsStore() .Returns(Task.FromResult(0)) .Verifiable(); store.Setup(s => s.UpdateAsync(user, CancellationToken.None)).Returns(Task.FromResult(IdentityResult.Success)).Verifiable(); - var userManager = MockHelpers.TestUserManager(store.Object); + var userManager = MockHelpers.TestUserManager(store.Object, meterFactory: testMeterFactory); // Act var result = await userManager.ReplaceClaimAsync(user, claim, newClaim); @@ -559,12 +613,24 @@ public async Task UpdateClaimCallsStore() // Assert Assert.True(result.Succeeded); store.VerifyAll(); + + Assert.Collection(updateUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "replace_claim"), + KeyValuePair.Create("aspnetcore.identity.result", "success") + ])); } [Fact] public async Task CheckPasswordWillRehashPasswordWhenNeeded() { // Setup + var testMeterFactory = new TestMeterFactory(); + using var updateUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.UpdateCounterName); + using var checkPassword = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.CheckPasswordCounterName); + using var verifyPassword = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.VerifyPasswordCounterName); + var store = new Mock>(); var hasher = new Mock>(); var user = new PocoUser { UserName = "Foo" }; @@ -580,7 +646,7 @@ public async Task CheckPasswordWillRehashPasswordWhenNeeded() hasher.Setup(s => s.VerifyHashedPassword(user, hashed, pwd)).Returns(PasswordVerificationResult.SuccessRehashNeeded).Verifiable(); hasher.Setup(s => s.HashPassword(user, pwd)).Returns(rehashed).Verifiable(); - var userManager = MockHelpers.TestUserManager(store.Object); + var userManager = MockHelpers.TestUserManager(store.Object, meterFactory: testMeterFactory); userManager.PasswordHasher = hasher.Object; // Act @@ -590,14 +656,33 @@ public async Task CheckPasswordWillRehashPasswordWhenNeeded() Assert.True(result); store.VerifyAll(); hasher.VerifyAll(); + + Assert.Collection(updateUser.GetMeasurementSnapshot(), m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "password_rehash"), + KeyValuePair.Create("aspnetcore.identity.result", "success") + ])); + Assert.Collection(checkPassword.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.password_result", "success_rehash_needed") + ])); + Assert.Collection(verifyPassword.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.password_result", "success_rehash_needed") + ])); } [Fact] public async Task CreateFailsWithNullSecurityStamp() { // Setup + var testMeterFactory = new TestMeterFactory(); + using var createUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.CreateCounterName); + var store = new Mock>(); - var manager = MockHelpers.TestUserManager(store.Object); + var manager = MockHelpers.TestUserManager(store.Object, meterFactory: testMeterFactory); var user = new PocoUser { UserName = "nulldude" }; store.Setup(s => s.GetSecurityStampAsync(user, It.IsAny())).ReturnsAsync(default(string)).Verifiable(); @@ -607,14 +692,24 @@ public async Task CreateFailsWithNullSecurityStamp() Assert.Contains(Extensions.Identity.Core.Resources.NullSecurityStamp, ex.Message); store.VerifyAll(); + + Assert.Collection(createUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), + KeyValuePair.Create("error.type", "System.InvalidOperationException") + ])); } [Fact] public async Task UpdateFailsWithNullSecurityStamp() { // Setup + var testMeterFactory = new TestMeterFactory(); + using var updateUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.UpdateCounterName); + var store = new Mock>(); - var manager = MockHelpers.TestUserManager(store.Object); + var manager = MockHelpers.TestUserManager(store.Object, meterFactory: testMeterFactory); var user = new PocoUser { UserName = "nulldude" }; store.Setup(s => s.GetSecurityStampAsync(user, It.IsAny())).ReturnsAsync(default(string)).Verifiable(); @@ -624,12 +719,22 @@ public async Task UpdateFailsWithNullSecurityStamp() Assert.Contains(Extensions.Identity.Core.Resources.NullSecurityStamp, ex.Message); store.VerifyAll(); + + Assert.Collection(updateUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "update"), + KeyValuePair.Create("error.type", "System.InvalidOperationException") + ])); } [Fact] public async Task RemoveClaimsCallsStore() { // Setup + var testMeterFactory = new TestMeterFactory(); + using var updateUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.UpdateCounterName); + var store = new Mock>(); var user = new PocoUser { UserName = "Foo" }; var claims = new Claim[] { new Claim("1", "1"), new Claim("2", "2"), new Claim("3", "3") }; @@ -637,7 +742,7 @@ public async Task RemoveClaimsCallsStore() .Returns(Task.FromResult(0)) .Verifiable(); store.Setup(s => s.UpdateAsync(user, CancellationToken.None)).ReturnsAsync(IdentityResult.Success).Verifiable(); - var userManager = MockHelpers.TestUserManager(store.Object); + var userManager = MockHelpers.TestUserManager(store.Object, meterFactory: testMeterFactory); // Act var result = await userManager.RemoveClaimsAsync(user, claims); @@ -645,12 +750,22 @@ public async Task RemoveClaimsCallsStore() // Assert Assert.True(result.Succeeded); store.VerifyAll(); + + Assert.Collection(updateUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "remove_claims"), + KeyValuePair.Create("aspnetcore.identity.result", "success") + ])); } [Fact] public async Task RemoveClaimCallsStore() { // Setup + var testMeterFactory = new TestMeterFactory(); + using var updateUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.UpdateCounterName); + var store = new Mock>(); var user = new PocoUser { UserName = "Foo" }; var claim = new Claim("1", "1"); @@ -658,7 +773,7 @@ public async Task RemoveClaimCallsStore() .Returns(Task.FromResult(0)) .Verifiable(); store.Setup(s => s.UpdateAsync(user, CancellationToken.None)).ReturnsAsync(IdentityResult.Success).Verifiable(); - var userManager = MockHelpers.TestUserManager(store.Object); + var userManager = MockHelpers.TestUserManager(store.Object, meterFactory: testMeterFactory); // Act var result = await userManager.RemoveClaimAsync(user, claim); @@ -666,13 +781,29 @@ public async Task RemoveClaimCallsStore() // Assert Assert.True(result.Succeeded); store.VerifyAll(); + + Assert.Collection(updateUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "remove_claims"), + KeyValuePair.Create("aspnetcore.identity.result", "success") + ])); } [Fact] public async Task CheckPasswordWithNullUserReturnsFalse() { - var manager = MockHelpers.TestUserManager(new EmptyStore()); + var testMeterFactory = new TestMeterFactory(); + using var checkPassword = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.CheckPasswordCounterName); + + var manager = MockHelpers.TestUserManager(new EmptyStore(), meterFactory: testMeterFactory); Assert.False(await manager.CheckPasswordAsync(null, "whatevs")); + + Assert.Collection(checkPassword.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.password_result", "user_missing") + ])); } [Fact] @@ -686,40 +817,92 @@ public void UsersQueryableFailWhenStoreNotImplemented() [Fact] public async Task UsersEmailMethodsFailWhenStoreNotImplemented() { - var manager = MockHelpers.TestUserManager(new NoopUserStore()); + var testMeterFactory = new TestMeterFactory(); + using var updateUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.UpdateCounterName); + + var manager = MockHelpers.TestUserManager(new NoopUserStore(), meterFactory: testMeterFactory); Assert.False(manager.SupportsUserEmail); await Assert.ThrowsAsync(() => manager.FindByEmailAsync(null)); await Assert.ThrowsAsync(() => manager.SetEmailAsync(null, null)); await Assert.ThrowsAsync(() => manager.GetEmailAsync(null)); await Assert.ThrowsAsync(() => manager.IsEmailConfirmedAsync(null)); await Assert.ThrowsAsync(() => manager.ConfirmEmailAsync(null, null)); + + Assert.Collection(updateUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "set_email"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ]), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "confirm_email"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ])); } [Fact] public async Task UsersPhoneNumberMethodsFailWhenStoreNotImplemented() { - var manager = MockHelpers.TestUserManager(new NoopUserStore()); + var testMeterFactory = new TestMeterFactory(); + using var updateUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.UpdateCounterName); + + var manager = MockHelpers.TestUserManager(new NoopUserStore(), meterFactory: testMeterFactory); Assert.False(manager.SupportsUserPhoneNumber); await Assert.ThrowsAsync(async () => await manager.SetPhoneNumberAsync(null, null)); - await Assert.ThrowsAsync(async () => await manager.SetPhoneNumberAsync(null, null)); + await Assert.ThrowsAsync(async () => await manager.ChangePhoneNumberAsync(null, null, null)); await Assert.ThrowsAsync(async () => await manager.GetPhoneNumberAsync(null)); + + Assert.Collection(updateUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "set_phone_number"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ]), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "change_phone_number"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ])); } [Fact] public async Task TokenMethodsThrowWithNoTokenProvider() { - var manager = MockHelpers.TestUserManager(new NoopUserStore()); + var testMeterFactory = new TestMeterFactory(); + using var generateToken = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.GenerateTokenCounterName); + using var verifyToken = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.VerifyTokenCounterName); + + var manager = MockHelpers.TestUserManager(new NoopUserStore(), meterFactory: testMeterFactory); var user = new PocoUser(); await Assert.ThrowsAsync( - async () => await manager.GenerateUserTokenAsync(user, "bogus", null)); + async () => await manager.GenerateUserTokenAsync(user, "bogus", "test-purpose")); await Assert.ThrowsAsync( - async () => await manager.VerifyUserTokenAsync(user, "bogus", null, null)); + async () => await manager.VerifyUserTokenAsync(user, "bogus", "test-purpose", null)); + + Assert.Collection(generateToken.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.token_purpose", "_UNKNOWN"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ])); + Assert.Collection(verifyToken.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.token_purpose", "_UNKNOWN"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ])); } [Fact] public async Task PasswordMethodsFailWhenStoreNotImplemented() { - var manager = MockHelpers.TestUserManager(new NoopUserStore()); + var testMeterFactory = new TestMeterFactory(); + using var createUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.CreateCounterName); + using var updateUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.UpdateCounterName); + using var checkPassword = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.CheckPasswordCounterName); + + var manager = MockHelpers.TestUserManager(new NoopUserStore(), meterFactory: testMeterFactory); Assert.False(manager.SupportsUserPassword); await Assert.ThrowsAsync(() => manager.CreateAsync(null, null)); await Assert.ThrowsAsync(() => manager.ChangePasswordAsync(null, null, null)); @@ -727,14 +910,45 @@ public async Task PasswordMethodsFailWhenStoreNotImplemented() await Assert.ThrowsAsync(() => manager.RemovePasswordAsync(null)); await Assert.ThrowsAsync(() => manager.CheckPasswordAsync(null, null)); await Assert.ThrowsAsync(() => manager.HasPasswordAsync(null)); + + Assert.Collection(createUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ])); + Assert.Collection(updateUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "change_password"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ]), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "add_password"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ]), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "remove_password"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ])); + Assert.Collection(checkPassword.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ])); } [Fact] public async Task SecurityStampMethodsFailWhenStoreNotImplemented() { + var testMeterFactory = new TestMeterFactory(); + using var updateUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.UpdateCounterName); + using var generateToken = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.GenerateTokenCounterName); + var store = new Mock>(); store.Setup(x => x.GetUserIdAsync(It.IsAny(), It.IsAny())).Returns(Task.FromResult(Guid.NewGuid().ToString())); - var manager = MockHelpers.TestUserManager(store.Object); + var manager = MockHelpers.TestUserManager(store.Object, meterFactory: testMeterFactory); Assert.False(manager.SupportsUserSecurityStamp); await Assert.ThrowsAsync(() => manager.UpdateSecurityStampAsync(null)); await Assert.ThrowsAsync(() => manager.GetSecurityStampAsync(null)); @@ -742,28 +956,76 @@ await Assert.ThrowsAsync( () => manager.VerifyChangePhoneNumberTokenAsync(new PocoUser(), "1", "111-111-1111")); await Assert.ThrowsAsync( () => manager.GenerateChangePhoneNumberTokenAsync(new PocoUser(), "111-111-1111")); + + Assert.Collection(updateUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "security_stamp"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ])); + Assert.Collection(generateToken.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.token_purpose", "change_phone_number"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ])); } [Fact] public async Task LoginMethodsFailWhenStoreNotImplemented() { - var manager = MockHelpers.TestUserManager(new NoopUserStore()); + var testMeterFactory = new TestMeterFactory(); + using var updateUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.UpdateCounterName); + + var manager = MockHelpers.TestUserManager(new NoopUserStore(), meterFactory: testMeterFactory); Assert.False(manager.SupportsUserLogin); await Assert.ThrowsAsync(async () => await manager.AddLoginAsync(null, null)); await Assert.ThrowsAsync(async () => await manager.RemoveLoginAsync(null, null, null)); await Assert.ThrowsAsync(async () => await manager.GetLoginsAsync(null)); await Assert.ThrowsAsync(async () => await manager.FindByLoginAsync(null, null)); + + Assert.Collection(updateUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "add_login"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ]), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "remove_login"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ])); } [Fact] public async Task ClaimMethodsFailWhenStoreNotImplemented() { - var manager = MockHelpers.TestUserManager(new NoopUserStore()); + var testMeterFactory = new TestMeterFactory(); + using var updateUser = new MetricCollector(testMeterFactory, "Microsoft.AspNetCore.Identity", UserManagerMetrics.UpdateCounterName); + + var manager = MockHelpers.TestUserManager(new NoopUserStore(), meterFactory: testMeterFactory); Assert.False(manager.SupportsUserClaim); await Assert.ThrowsAsync(async () => await manager.AddClaimAsync(null, null)); await Assert.ThrowsAsync(async () => await manager.ReplaceClaimAsync(null, null, null)); await Assert.ThrowsAsync(async () => await manager.RemoveClaimAsync(null, null)); await Assert.ThrowsAsync(async () => await manager.GetClaimsAsync(null)); + + Assert.Collection(updateUser.GetMeasurementSnapshot(), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "add_claims"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ]), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "replace_claim"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ]), + m => MetricsHelpers.AssertContainsTags(m.Tags, + [ + KeyValuePair.Create("aspnetcore.identity.user.update_type", "remove_claims"), + KeyValuePair.Create("error.type", "System.NotSupportedException"), + ])); } private class ATokenProvider : IUserTwoFactorTokenProvider @@ -1027,9 +1289,9 @@ public async Task ManagerPublicNullChecks() async () => await manager.CreateAsync(new PocoUser(), null)); await Assert.ThrowsAsync("user", async () => await manager.UpdateAsync(null)); await Assert.ThrowsAsync("user", async () => await manager.DeleteAsync(null)); - await Assert.ThrowsAsync("claim", async () => await manager.AddClaimAsync(null, null)); - await Assert.ThrowsAsync("claim", async () => await manager.ReplaceClaimAsync(null, null, null)); - await Assert.ThrowsAsync("claims", async () => await manager.AddClaimsAsync(null, null)); + await Assert.ThrowsAsync("user", async () => await manager.AddClaimAsync(null, null)); + await Assert.ThrowsAsync("user", async () => await manager.ReplaceClaimAsync(null, null, null)); + await Assert.ThrowsAsync("user", async () => await manager.AddClaimsAsync(null, null)); await Assert.ThrowsAsync("userName", async () => await manager.FindByNameAsync(null)); await Assert.ThrowsAsync("login", async () => await manager.AddLoginAsync(null, null)); await Assert.ThrowsAsync("loginProvider", diff --git a/src/Identity/test/Shared/MetricsHelpers.cs b/src/Identity/test/Shared/MetricsHelpers.cs new file mode 100644 index 000000000000..ab86e504eea9 --- /dev/null +++ b/src/Identity/test/Shared/MetricsHelpers.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity.Test; + +public static class MetricsHelpers +{ + public static void AssertContainsTags(IReadOnlyDictionary tags, List> expectedTags) + { + var found = 0; + foreach (var expectedTag in expectedTags) + { + if (tags.TryGetValue(expectedTag.Key, out var value) && EqualityComparer.Default.Equals(value, expectedTag.Value)) + { + found++; + } + } + if (found != expectedTags.Count) + { + throw new InvalidOperationException( + $""" + Expected: {string.Join(", ", expectedTags.OrderBy(t => t.Key).Select(kvp => $"{kvp.Key}={kvp.Value}"))} + Actual: {string.Join(", ", tags.OrderBy(t => t.Key).Select(kvp => $"{kvp.Key}={kvp.Value}"))} + """); + } + } +} diff --git a/src/Identity/test/Shared/MockHelpers.cs b/src/Identity/test/Shared/MockHelpers.cs index 3e3371083bda..e546283b9e85 100644 --- a/src/Identity/test/Shared/MockHelpers.cs +++ b/src/Identity/test/Shared/MockHelpers.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.Metrics; using System.Text; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; @@ -12,10 +14,17 @@ public static class MockHelpers { public static StringBuilder LogMessage = new StringBuilder(); - public static Mock> MockUserManager() where TUser : class + public static Mock> MockUserManager(IMeterFactory meterFactory = null) where TUser : class { + var services = new ServiceCollection(); + if (meterFactory != null) + { + services.AddSingleton(); + services.AddSingleton(meterFactory); + } + var store = new Mock>(); - var mgr = new Mock>(store.Object, null, null, null, null, null, null, null, null); + var mgr = new Mock>(store.Object, null, null, null, null, null, null, services.BuildServiceProvider(), null); mgr.Object.UserValidators.Add(new UserValidator()); mgr.Object.PasswordValidators.Add(new PasswordValidator()); return mgr; @@ -30,7 +39,7 @@ public static Mock> MockRoleManager(IRoleStore new IdentityErrorDescriber(), null); } - public static UserManager TestUserManager(IUserStore store = null) where TUser : class + public static UserManager TestUserManager(IUserStore store = null, IMeterFactory meterFactory = null) where TUser : class { store = store ?? new Mock>().Object; var options = new Mock>(); @@ -42,9 +51,16 @@ public static UserManager TestUserManager(IUserStore store userValidators.Add(validator.Object); var pwdValidators = new List>(); pwdValidators.Add(new PasswordValidator()); + var services = new ServiceCollection(); + if (meterFactory != null) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(meterFactory); + } var userManager = new UserManager(store, options.Object, new PasswordHasher(), userValidators, pwdValidators, MockLookupNormalizer(), - new IdentityErrorDescriber(), null, + new IdentityErrorDescriber(), services.BuildServiceProvider(), new Mock>>().Object); validator.Setup(v => v.ValidateAsync(userManager, It.IsAny())) .Returns(Task.FromResult(IdentityResult.Success)).Verifiable();