From 76079374ba6bbbf56da0f9034b90942ec2415e07 Mon Sep 17 00:00:00 2001 From: Anders Abel Date: Tue, 14 Nov 2023 12:15:32 +0100 Subject: [PATCH] Add events to configuration UI --- .../Pages/Account/Login/Index.cshtml.cs | 5 +- .../Pages/Account/Logout/Index.cshtml.cs | 7 +- .../Pages/Ciba/Consent.cshtml.cs | 4 + .../Pages/Consent/Index.cshtml.cs | 4 + .../Pages/Device/Index.cshtml.cs | 4 + .../Pages/ExternalLogin/Callback.cshtml.cs | 1 + .../Pages/Grants/Index.cshtml.cs | 1 + hosts/Configuration/Pages/Telemetry.cs | 170 ++++++++++++++++++ 8 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 hosts/Configuration/Pages/Telemetry.cs diff --git a/hosts/Configuration/Pages/Account/Login/Index.cshtml.cs b/hosts/Configuration/Pages/Account/Login/Index.cshtml.cs index 9e07c5b65..249f75b05 100644 --- a/hosts/Configuration/Pages/Account/Login/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Account/Login/Index.cshtml.cs @@ -97,6 +97,7 @@ public async Task OnPost() { var user = _users.FindByUsername(Input.Username); await _events.RaiseAsync(new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username, clientId: context?.Client.ClientId)); + Telemetry.Metrics.UserLogin(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider); // only set explicit expiration here if user chooses "remember me". // otherwise we rely upon expiration configured in cookie middleware. @@ -144,7 +145,9 @@ public async Task OnPost() } } - await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, "invalid credentials", clientId:context?.Client.ClientId)); + const string error = "invalid credentials"; + await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, error, clientId:context?.Client.ClientId)); + Telemetry.Metrics.UserLoginFailure(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider, error); ModelState.AddModelError(string.Empty, LoginOptions.InvalidCredentialsErrorMessage); } diff --git a/hosts/Configuration/Pages/Account/Logout/Index.cshtml.cs b/hosts/Configuration/Pages/Account/Logout/Index.cshtml.cs index 9f9c4dc1e..aef1de3d8 100644 --- a/hosts/Configuration/Pages/Account/Logout/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Account/Logout/Index.cshtml.cs @@ -71,12 +71,13 @@ public async Task OnPost() // delete local authentication cookie await HttpContext.SignOutAsync(); - // raise the logout event - await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); - // see if we need to trigger federated logout var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value; + // raise the logout event + await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); + Telemetry.Metrics.UserLogout(idp); + // if it's a local login we can ignore this workflow if (idp != null && idp != Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider) { diff --git a/hosts/Configuration/Pages/Ciba/Consent.cshtml.cs b/hosts/Configuration/Pages/Ciba/Consent.cshtml.cs index 4ebf8cd53..f615fdf05 100644 --- a/hosts/Configuration/Pages/Ciba/Consent.cshtml.cs +++ b/hosts/Configuration/Pages/Ciba/Consent.cshtml.cs @@ -69,6 +69,7 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDeniedEvent(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -90,6 +91,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, result.ScopesValuesConsented, false)); + Telemetry.Metrics.ConsentGrantedEvent(request.Client.ClientId, result.ScopesValuesConsented, false); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(result.ScopesValuesConsented); + Telemetry.Metrics.ConsentDeniedEvent(request.Client.ClientId, denied); } else { diff --git a/hosts/Configuration/Pages/Consent/Index.cshtml.cs b/hosts/Configuration/Pages/Consent/Index.cshtml.cs index 2abdee20a..357691a39 100644 --- a/hosts/Configuration/Pages/Consent/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Consent/Index.cshtml.cs @@ -66,6 +66,7 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDeniedEvent(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -88,6 +89,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGrantedEvent(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDeniedEvent(request.Client.ClientId, denied); } else { diff --git a/hosts/Configuration/Pages/Device/Index.cshtml.cs b/hosts/Configuration/Pages/Device/Index.cshtml.cs index f84bbb159..0d1721e44 100644 --- a/hosts/Configuration/Pages/Device/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Device/Index.cshtml.cs @@ -78,6 +78,7 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDeniedEvent(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -100,6 +101,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGrantedEvent(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDeniedEvent(request.Client.ClientId, denied); } else { diff --git a/hosts/Configuration/Pages/ExternalLogin/Callback.cshtml.cs b/hosts/Configuration/Pages/ExternalLogin/Callback.cshtml.cs index 937ced8e9..281131bc2 100644 --- a/hosts/Configuration/Pages/ExternalLogin/Callback.cshtml.cs +++ b/hosts/Configuration/Pages/ExternalLogin/Callback.cshtml.cs @@ -106,6 +106,7 @@ public async Task OnGet() // check if external login is in the context of an OIDC request var context = await _interaction.GetAuthorizationContextAsync(returnUrl); await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.SubjectId, user.Username, true, context?.Client.ClientId)); + Telemetry.Metrics.UserLogin(context?.Client.ClientId, provider!); if (context != null) { diff --git a/hosts/Configuration/Pages/Grants/Index.cshtml.cs b/hosts/Configuration/Pages/Grants/Index.cshtml.cs index dceca1034..a38545744 100644 --- a/hosts/Configuration/Pages/Grants/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Grants/Index.cshtml.cs @@ -75,6 +75,7 @@ public async Task OnPost() { await _interaction.RevokeUserConsentAsync(ClientId); await _events.RaiseAsync(new GrantsRevokedEvent(User.GetSubjectId(), ClientId)); + Telemetry.Metrics.GrantsRevoked(ClientId); return RedirectToPage("/Grants/Index"); } diff --git a/hosts/Configuration/Pages/Telemetry.cs b/hosts/Configuration/Pages/Telemetry.cs new file mode 100644 index 000000000..15a993c6d --- /dev/null +++ b/hosts/Configuration/Pages/Telemetry.cs @@ -0,0 +1,170 @@ +using Duende.IdentityServer.Events; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Diagnostics.Metrics; + +namespace IdentityServerHost.Pages; + +#pragma warning disable CA1034 // Nested types should not be visible +#pragma warning disable CA1724 // Type names should not match namespaces + +/// +/// Telemetry helpers for the UI +/// +public static class Telemetry +{ + private static readonly string ServiceVersion = typeof(Telemetry).Assembly.GetName().Version!.ToString(); + + /// + /// Service name for telemetry. + /// + public static readonly string ServiceName = typeof(Telemetry).Assembly.GetName().Name!; + + /// + /// Metrics configuration + /// + public static class Metrics + { + /// + /// Name of Counters + /// + public static class Counters + { + /// + /// consent_granted + /// + public const string ConsentGranted = "consent_granted"; + + /// + /// consent_denied + /// + public const string ConsentDenied = "consent_denied"; + + /// + /// grants_revoked + /// + public const string GrantsRevoked = "grants_revoked"; + + /// + /// user_login + /// + public const string UserLogin = "user_login"; + + /// + /// user_login_failure + /// + public const string UserLoginFailure = "user_login_failure"; + + /// + /// user_logout + /// + public const string UserLogout = "user_logout"; + } + + /// + /// Name of tags + /// + public static class Tags + { + /// + /// client + /// + public const string Client = "client"; + + /// + /// error + /// + public const string Error = "error"; + + /// + /// idp + /// + public const string Idp = "idp"; + + /// + /// remember + /// + public const string Remember = "remember"; + + /// + /// scope + /// + public const string Scope = "scope"; + } + + /// + /// Meter for the IdentityServer host project + /// + private static readonly Meter Meter = new Meter(ServiceName, ServiceVersion); + + private static Counter ConsentGrantedCounter = Meter.CreateCounter(Counters.ConsentGranted); + + /// + /// Helper method to increase counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// + /// Client id + /// Scope names. Each element is added on it's own to the counter + public static void ConsentGrantedEvent(string clientId, IEnumerable scopes, bool remember) + { + ArgumentNullException.ThrowIfNull(scopes); + foreach(var scope in scopes) + { + ConsentGrantedCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope), new(Tags.Remember, remember)); + } + } + + private static Counter ConsentDeniedCounter = Meter.CreateCounter(Counters.ConsentDenied); + + /// + /// Helper method to increase counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// + /// Client id + /// Scope names. Each element is added on it's own to the counter + public static void ConsentDeniedEvent(string clientId, IEnumerable scopes) + { + ArgumentNullException.ThrowIfNull(scopes); + foreach (var scope in scopes) + { + ConsentDeniedCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope)); + } + } + + private static Counter GrantsRevokedCounter = Meter.CreateCounter(Counters.GrantsRevoked); + + /// + /// Helper method to increase the counter. + /// + /// Client id to revoke for, or null for all. + public static void GrantsRevoked(string? clientId) + => GrantsRevokedCounter.Add(1, tag: new(Tags.Client, clientId)); + + private static Counter UserLoginCounter = Meter.CreateCounter(Counters.UserLogin); + + /// + /// Helper method to increase counter. + /// + /// Client Id, if available + public static void UserLogin(string? clientId, string idp) + => UserLoginCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp)); + + private static Counter UserLoginFailureCounter = Meter.CreateCounter(Counters.UserLoginFailure); + + /// + /// Helper method to increase + /// Client Id, if available + /// Error message + public static void UserLoginFailure(string? clientId, string idp, string error) + => UserLoginFailureCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp), new(Tags.Error, error)); + + private static Counter UserLogoutCounter = Meter.CreateCounter(Counters.UserLogout); + + /// + /// Helper method to increase the counter. + /// + /// Idp/authentication scheme for external authentication, or "local" for built in. + public static void UserLogout(string? idp) + => UserLogoutCounter.Add(1, tag: new(Tags.Idp, idp)); + } +}