From a8e51b557ecbb3cbee4ea4820ad01269b3bda21b Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 20 Aug 2026 22:25:23 -0700 Subject: [PATCH 1/2] RG-T133 User Session, Password Reset Changes, More Audits --- Core/Resgrid.Config/OidcConfig.cs | 8 +- Core/Resgrid.Config/SessionSecurityConfig.cs | 24 + .../Areas/User/Department/Department.en.resx | 8 +- .../Areas/User/Department/Department.resx | 8 +- .../Areas/User/Profile/Profile.ar.resx | 3 + .../Areas/User/Profile/Profile.de.resx | 3 + .../Areas/User/Profile/Profile.el.resx | 5 +- .../Areas/User/Profile/Profile.en.resx | 5 +- .../Areas/User/Profile/Profile.es.resx | 5 +- .../Areas/User/Profile/Profile.fr.resx | 3 + .../Areas/User/Profile/Profile.it.resx | 3 + .../Areas/User/Profile/Profile.pl.resx | 3 + .../Areas/User/Profile/Profile.sv.resx | 3 + .../Areas/User/Profile/Profile.uk.resx | 3 + Core/Resgrid.Model/AuditLogTypes.cs | 4 +- Core/Resgrid.Model/DepartmentSettingTypes.cs | 6 + .../ExternalIdentityLinkMethod.cs | 11 + Core/Resgrid.Model/Identity/IdentityUser.cs | 15 + .../Resgrid.Model/Providers/IEmailProvider.cs | 6 +- .../Repositories/IIdentityRepository.cs | 14 - .../IUserExternalIdentityLinksRepository.cs | 12 + .../Repositories/IUserSessionsRepository.cs | 22 + .../Security/PasswordRecoveryContracts.cs | 21 + .../Security/SessionClaimTypes.cs | 9 + .../SessionCreationDeniedException.cs | 15 + .../Security/UserSessionContracts.cs | 124 ++++ .../Services/IClientSessionMetadataParser.cs | 10 + .../Services/IDepartmentSettingsService.cs | 6 + Core/Resgrid.Model/Services/IEmailService.cs | 16 +- .../Services/IExternalIdentityLinkService.cs | 15 + .../Services/IIpLocationProvider.cs | 12 + .../Services/IPasswordRecoveryService.cs | 16 + .../Services/IUserSessionService.cs | 24 + Core/Resgrid.Model/Services/IUsersService.cs | 2 - Core/Resgrid.Model/SystemAudit.cs | 6 + Core/Resgrid.Model/SystemAuditSystems.cs | 3 +- Core/Resgrid.Model/SystemAuditTypes.cs | 13 +- .../Resgrid.Model/UserExternalIdentityLink.cs | 44 ++ Core/Resgrid.Model/UserSession.cs | 63 ++ .../UserSessionAuthenticationMethod.cs | 11 + .../UserSessionClientApplication.cs | 15 + .../UserSessionRevocationReason.cs | 20 + Core/Resgrid.Model/UserSessionState.cs | 9 + .../ClientSessionMetadataParser.cs | 83 +++ Core/Resgrid.Services/CommunicationService.cs | 37 +- Core/Resgrid.Services/DeleteService.cs | 10 +- .../DepartmentSettingsService.cs | 23 + Core/Resgrid.Services/DepartmentSsoService.cs | 136 ++++- Core/Resgrid.Services/EmailService.cs | 27 +- .../ExternalIdentityLinkService.cs | 110 ++++ .../LocalIpLocationProvider.cs | 133 +++++ .../PasswordRecoveryService.cs | 107 ++++ Core/Resgrid.Services/ServicesModule.cs | 5 + Core/Resgrid.Services/UserSessionService.cs | 340 +++++++++++ Core/Resgrid.Services/UsersService.cs | 22 - .../Resgrid.Providers.Bus/SignalrProvider.cs | 62 +- .../ClaimsPrincipalFactory.cs | 3 + .../PostmarkTemplateProvider.cs | 58 +- .../Resgrid.Providers.Email.csproj | 8 +- .../PasswordChangedByAdministrator.html | 35 ++ .../Template/PasswordRecovery.html | 48 ++ .../Template/PasswordReset.html | 491 ---------------- .../M0120_AddUserAuthenticationState.cs | 30 + .../Migrations/M0121_AddUserSessions.cs | 69 +++ .../M0122_AddUserExternalIdentityLinks.cs | 53 ++ .../M0123_AddAuthenticationAuditContext.cs | 28 + .../M0120_AddUserAuthenticationStatePg.cs | 30 + .../Migrations/M0121_AddUserSessionsPg.cs | 72 +++ .../M0122_AddUserExternalIdentityLinksPg.cs | 53 ++ .../M0123_AddAuthenticationAuditContextPg.cs | 28 + .../IdentityRepository.cs | 50 +- .../Modules/ApiDataModule.cs | 2 + .../Modules/DataModule.cs | 2 + .../Modules/NonWebDataModule.cs | 2 + .../Modules/TestingDataModule.cs | 2 + .../UserExternalIdentityLinksRepository.cs | 73 +++ .../UserSessionsRepository.cs | 186 ++++++ .../ClientSessionMetadataParserTests.cs | 35 ++ .../Services/CommunicationServiceTests.cs | 60 ++ ...rtmentSettingsServicePasswordResetTests.cs | 94 +++ .../Services/DepartmentSsoServiceTests.cs | 3 +- .../ExternalIdentityLinkServiceTests.cs | 97 ++++ .../Services/LocalIpLocationProviderTests.cs | 42 ++ .../Services/PasswordRecoveryServiceTests.cs | 87 +++ .../Services/UserSessionServiceTests.cs | 223 ++++++++ .../Web/Services/ConnectControllerSsoTests.cs | 4 +- .../Commands/ResetPasswordCommand.cs | 33 +- Web/Resgrid.Web.Eventing/Hubs/EventingHub.cs | 175 ++---- .../Middleware/SessionValidationHubFilter.cs | 116 ++++ .../Middleware/SessionValidationMiddleware.cs | 108 ++++ Web/Resgrid.Web.Eventing/Startup.cs | 20 +- Web/Resgrid.Web.Mcp/Startup.cs | 3 + .../Controllers/v4/ConnectController.cs | 370 ++++++++++-- .../Controllers/v4/ScimController.cs | 36 +- .../Controllers/v4/SessionsController.cs | 106 ++++ .../Helpers/IpAddressHelper.cs | 9 +- Web/Resgrid.Web.Services/Hubs/EventingHub.cs | 106 ++-- .../Middleware/SessionValidationHubFilter.cs | 112 ++++ .../Middleware/SessionValidationMiddleware.cs | 124 ++++ .../Resgrid.Web.Services.xml | 13 + Web/Resgrid.Web.Services/Startup.cs | 19 +- .../User/Apps/src/components/chat/chatHub.ts | 8 +- .../Areas/User/Apps/src/runtime/api.ts | 16 +- .../Areas/User/Apps/src/runtime/auth.ts | 40 -- .../User/Apps/src/runtime/browserConfig.ts | 4 +- .../User/Apps/src/runtime/eventingToken.ts | 21 + .../Areas/User/Apps/src/runtime/signalr.ts | 10 +- .../User/Controllers/AccountController.cs | 6 +- .../Controllers/AccountSecurityController.cs | 236 ++++++++ .../User/Controllers/DepartmentController.cs | 3 + .../Areas/User/Controllers/HomeController.cs | 145 +++-- .../User/Controllers/PersonnelController.cs | 32 +- .../User/Controllers/ProfileController.cs | 488 +++++++++++----- .../User/Models/DepartmentSettingsModel.cs | 1 + .../Areas/User/Models/EditProfileModel.cs | 26 +- .../User/Models/Personnel/PersonnelForJson.cs | 2 + .../Areas/User/Models/PersonnelModel.cs | 1 + .../Profile/ResetPasswordForUserView.cs | 4 + .../Models/Security/AccountCredentialViews.cs | 41 ++ .../Models/Security/ActiveSessionsView.cs | 11 + .../AccountSecurity/ChangePassword.cshtml | 26 + .../AccountSecurity/ChangeUsername.cshtml | 21 + .../Views/AccountSecurity/Sessions.cshtml | 85 +++ .../User/Views/Department/Settings.cshtml | 11 + .../User/Views/Home/EditUserProfile.cshtml | 65 +-- .../Areas/User/Views/Personnel/Index.cshtml | 8 +- .../Views/Profile/ResetPasswordForUser.cshtml | 39 +- .../User/Views/Profile/YourDepartments.cshtml | 13 +- .../User/Views/Shared/_Navigation.cshtml | 7 +- .../Areas/User/Views/Shared/_TopNavbar.cshtml | 7 +- .../User/Views/Shared/_UserLayout.cshtml | 8 +- .../User/Views/WeatherAlerts/History.cshtml | 14 +- .../User/Views/WeatherAlerts/Index.cshtml | 14 +- .../User/Views/WeatherAlerts/Settings.cshtml | 31 +- .../User/Views/WeatherAlerts/Zones.cshtml | 24 +- .../Controllers/AccountController.cs | 536 ++++++++++++++---- .../Controllers/WebApiBffController.cs | 198 +++++++ Web/Resgrid.Web/Helpers/ApiAuthHelper.cs | 57 +- Web/Resgrid.Web/Helpers/IpAddressHelper.cs | 11 +- Web/Resgrid.Web/Helpers/JavasriptHelpers.cs | 19 +- .../Middleware/SessionValidationMiddleware.cs | 143 +++++ .../ForcePasswordChangeViewModel.cs | 4 + .../ResetPasswordViewModel.cs | 12 +- Web/Resgrid.Web/Startup.cs | 31 +- .../Views/Account/ForcePasswordChange.cshtml | 1 + .../Views/Account/ResetPassword.cshtml | 81 ++- Web/Resgrid.Web/Views/Shared/Error.cshtml | 2 +- .../Views/Shared/Unauthorized.cshtml | 2 +- .../Views/Shared/_RecoveryLayout.cshtml | 20 + .../resgrid.dispatch.addArchivedCall.js | 16 +- .../dispatch/resgrid.dispatch.editcall.js | 16 +- .../dispatch/resgrid.dispatch.newcall.js | 16 +- .../resgrid.profile.yourdepartments.js | 11 +- .../internal/routes/resgrid.routes.edit.js | 18 +- .../app/internal/routes/resgrid.routes.new.js | 18 +- .../app/public/resgrid.password-recovery.js | 28 + .../Tasks/CleanOIDCScheduleTask.cs | 4 + 157 files changed, 6128 insertions(+), 1625 deletions(-) create mode 100644 Core/Resgrid.Config/SessionSecurityConfig.cs create mode 100644 Core/Resgrid.Model/ExternalIdentityLinkMethod.cs create mode 100644 Core/Resgrid.Model/Repositories/IUserExternalIdentityLinksRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IUserSessionsRepository.cs create mode 100644 Core/Resgrid.Model/Security/PasswordRecoveryContracts.cs create mode 100644 Core/Resgrid.Model/Security/SessionClaimTypes.cs create mode 100644 Core/Resgrid.Model/Security/SessionCreationDeniedException.cs create mode 100644 Core/Resgrid.Model/Security/UserSessionContracts.cs create mode 100644 Core/Resgrid.Model/Services/IClientSessionMetadataParser.cs create mode 100644 Core/Resgrid.Model/Services/IExternalIdentityLinkService.cs create mode 100644 Core/Resgrid.Model/Services/IIpLocationProvider.cs create mode 100644 Core/Resgrid.Model/Services/IPasswordRecoveryService.cs create mode 100644 Core/Resgrid.Model/Services/IUserSessionService.cs create mode 100644 Core/Resgrid.Model/UserExternalIdentityLink.cs create mode 100644 Core/Resgrid.Model/UserSession.cs create mode 100644 Core/Resgrid.Model/UserSessionAuthenticationMethod.cs create mode 100644 Core/Resgrid.Model/UserSessionClientApplication.cs create mode 100644 Core/Resgrid.Model/UserSessionRevocationReason.cs create mode 100644 Core/Resgrid.Model/UserSessionState.cs create mode 100644 Core/Resgrid.Services/ClientSessionMetadataParser.cs create mode 100644 Core/Resgrid.Services/ExternalIdentityLinkService.cs create mode 100644 Core/Resgrid.Services/LocalIpLocationProvider.cs create mode 100644 Core/Resgrid.Services/PasswordRecoveryService.cs create mode 100644 Core/Resgrid.Services/UserSessionService.cs create mode 100644 Providers/Resgrid.Providers.Email/Template/PasswordChangedByAdministrator.html create mode 100644 Providers/Resgrid.Providers.Email/Template/PasswordRecovery.html delete mode 100644 Providers/Resgrid.Providers.Email/Template/PasswordReset.html create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0120_AddUserAuthenticationState.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0121_AddUserSessions.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0122_AddUserExternalIdentityLinks.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0123_AddAuthenticationAuditContext.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0120_AddUserAuthenticationStatePg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0121_AddUserSessionsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0122_AddUserExternalIdentityLinksPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0123_AddAuthenticationAuditContextPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/UserExternalIdentityLinksRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/UserSessionsRepository.cs create mode 100644 Tests/Resgrid.Tests/Services/ClientSessionMetadataParserTests.cs create mode 100644 Tests/Resgrid.Tests/Services/DepartmentSettingsServicePasswordResetTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ExternalIdentityLinkServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/LocalIpLocationProviderTests.cs create mode 100644 Tests/Resgrid.Tests/Services/PasswordRecoveryServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/UserSessionServiceTests.cs create mode 100644 Web/Resgrid.Web.Eventing/Middleware/SessionValidationHubFilter.cs create mode 100644 Web/Resgrid.Web.Eventing/Middleware/SessionValidationMiddleware.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/SessionsController.cs create mode 100644 Web/Resgrid.Web.Services/Middleware/SessionValidationHubFilter.cs create mode 100644 Web/Resgrid.Web.Services/Middleware/SessionValidationMiddleware.cs delete mode 100644 Web/Resgrid.Web/Areas/User/Apps/src/runtime/auth.ts create mode 100644 Web/Resgrid.Web/Areas/User/Apps/src/runtime/eventingToken.ts create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Security/AccountCredentialViews.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Security/ActiveSessionsView.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/AccountSecurity/ChangePassword.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/AccountSecurity/ChangeUsername.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/AccountSecurity/Sessions.cshtml create mode 100644 Web/Resgrid.Web/Controllers/WebApiBffController.cs create mode 100644 Web/Resgrid.Web/Middleware/SessionValidationMiddleware.cs create mode 100644 Web/Resgrid.Web/Views/Shared/_RecoveryLayout.cshtml create mode 100644 Web/Resgrid.Web/wwwroot/js/app/public/resgrid.password-recovery.js diff --git a/Core/Resgrid.Config/OidcConfig.cs b/Core/Resgrid.Config/OidcConfig.cs index 4cf2df180..195cfe5c2 100644 --- a/Core/Resgrid.Config/OidcConfig.cs +++ b/Core/Resgrid.Config/OidcConfig.cs @@ -13,7 +13,7 @@ public static class OidcConfig public static string Key = ""; - public static string ConnectionString = "Server=rgdevserver;Database=ResgridOIDC;User Id=resgrid_odic;Password=resgrid123;MultipleActiveResultSets=True;TrustServerCertificate=True;"; + public static string ConnectionString = ""; public static int AccessTokenExpiryMinutes = 1440; @@ -21,6 +21,12 @@ public static class OidcConfig public static int NonMobileRefreshTokenExpiryDays = 2; + /// + /// Comma-separated, registered client IDs allowed to receive the longer mobile + /// refresh-token lifetime. Anonymous requests and caller-supplied scopes never qualify. + /// + public static string TrustedLongLivedClientIds = ""; + public static string EncryptionCert = ""; public static string SigningCert = ""; diff --git a/Core/Resgrid.Config/SessionSecurityConfig.cs b/Core/Resgrid.Config/SessionSecurityConfig.cs new file mode 100644 index 000000000..a08d7a77a --- /dev/null +++ b/Core/Resgrid.Config/SessionSecurityConfig.cs @@ -0,0 +1,24 @@ +namespace Resgrid.Config +{ + public static class SessionSecurityConfig + { + // Session tracking is required by the Web BFF and is safe for pre-feature + // credentials because they are adopted lazily by the validation middleware. + public static bool TrackingEnabled = true; + public static bool LegacyAdoptionEnabled = true; + public static string RequireSessionClaimForCredentialsIssuedAfterUtc = ""; + // Blank is intentionally disabled at launch. Set to an ISO-8601 UTC timestamp + // only after previewing stored DepartmentSecurityPolicy session values. + public static string DepartmentSessionPolicyEnforcementAfterUtc = ""; + public static int LastActivityWriteIntervalMinutes = 5; + public static int RevokedSessionRetentionDays = 90; + public static int PublicResetLinkLifetimeMinutes = 30; + public static int PublicResetAccountLimitPerHour = 3; + public static int PublicResetIpLimitPerHour = 10; + public static int WebBffAccessTokenLifetimeMinutes = 5; + public static int ClientMetadataMaximumLength = 256; + public static int UserAgentMaximumLength = 1024; + // Optional local JSON CIDR database. Leave blank to display location as unavailable. + public static string IpLocationDatabasePath = ""; + } +} diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx index 66e1ae550..c8a7de28a 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx @@ -1080,4 +1080,10 @@ Returning - \ No newline at end of file + + Require password resets by email + + + Prevents department and group administrators from choosing or seeing a member's new password. Reset actions send the member a short-lived, single-use email link instead. Existing sessions are revoked after the member successfully changes the password. + + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.resx index b40abeb07..888b3c016 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.resx @@ -675,4 +675,10 @@ Returning - \ No newline at end of file + + Require password resets by email + + + Prevents department and group administrators from choosing or seeing a member's new password. Reset actions send the member a short-lived, single-use email link instead. Existing sessions are revoked after the member successfully changes the password. + + diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.ar.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.ar.resx index fe0e8005e..1a85f85f5 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.ar.resx @@ -72,4 +72,7 @@ على الأقل {0} أحرف رقم واحد على الأقل حرف كبير وحرف صغير على الأقل + إرسال بريد إلكتروني لإعادة تعيين كلمة المرور + إرسال بريد إلكتروني لإعادة تعيين كلمة المرور + سيرسل Resgrid إلى هذا المستخدم رابطًا قصير الصلاحية يُستخدم مرة واحدة إلى عنوان بريده الإلكتروني المؤكد. لن يختار المسؤول كلمة المرور الجديدة ولن يراها. diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx index 7f94abfa7..4c5c906b4 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx @@ -251,4 +251,7 @@ Mindestens {0} Zeichen lang Mindestens eine Ziffer (Zahl) Mindestens ein Groß- und ein Kleinbuchstabe + E-Mail zum Zurücksetzen des Passworts senden + E-Mail zum Zurücksetzen des Passworts senden + Resgrid sendet diesem Benutzer einen kurzlebigen, einmal verwendbaren Link an seine bestätigte E-Mail-Adresse. Der Administrator wählt oder sieht das neue Passwort nicht. diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.el.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.el.resx index 8985be1f0..0edd3105b 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.el.resx @@ -300,4 +300,7 @@ Τουλάχιστον {0} χαρακτήρες Τουλάχιστον ένα ψηφίο (αριθμός) Τουλάχιστον ένα κεφαλαίο και ένα πεζό γράμμα - \ No newline at end of file + Αποστολή email επαναφοράς κωδικού πρόσβασης + Αποστολή email επαναφοράς κωδικού πρόσβασης + Το Resgrid θα στείλει σε αυτόν τον χρήστη έναν σύνδεσμο σύντομης διάρκειας και μίας χρήσης στην επιβεβαιωμένη διεύθυνση email του. Ο διαχειριστής δεν θα επιλέξει ούτε θα δει τον νέο κωδικό πρόσβασης. + diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.en.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.en.resx index abfa02432..2abe6c267 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.en.resx @@ -300,4 +300,7 @@ At least {0} characters long At least one digit (number) At least one uppercase and one lowercase letter - \ No newline at end of file + Send Password Reset Email + Send Password Reset Email + Resgrid will send this user a short-lived, single-use link at their confirmed email address. The administrator will not choose or see the new password. + diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.es.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.es.resx index 84912acef..0aabbe912 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.es.resx @@ -300,4 +300,7 @@ Al menos {0} caracteres Al menos un dígito (número) Al menos una letra mayúscula y una minúscula - \ No newline at end of file + Enviar correo de restablecimiento de contraseña + Enviar correo de restablecimiento de contraseña + Resgrid enviará a este usuario un enlace de corta duración y de un solo uso a su dirección de correo confirmada. El administrador no elegirá ni verá la nueva contraseña. + diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx index 7fdb8114e..038059d5e 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx @@ -251,4 +251,7 @@ Au moins {0} caractères Au moins un chiffre Au moins une lettre majuscule et une minuscule + Envoyer l’e-mail de réinitialisation du mot de passe + Envoyer l’e-mail de réinitialisation du mot de passe + Resgrid enverra à cet utilisateur un lien à courte durée de vie et à usage unique à son adresse e-mail confirmée. L’administrateur ne choisira ni ne verra le nouveau mot de passe. diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx index eb28f28c3..d5ad592af 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx @@ -251,4 +251,7 @@ Almeno {0} caratteri Almeno una cifra (numero) Almeno una lettera maiuscola e una minuscola + Invia email di reimpostazione password + Invia email di reimpostazione password + Resgrid invierà a questo utente un link di breve durata e monouso al suo indirizzo email confermato. L’amministratore non sceglierà né vedrà la nuova password. diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx index 855a18f3e..fdb8f5f0e 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx @@ -251,4 +251,7 @@ Co najmniej {0} znaków Co najmniej jedna cyfra Co najmniej jedna wielka i jedna mała litera + Wyślij wiadomość e-mail do resetowania hasła + Wyślij wiadomość e-mail do resetowania hasła + Resgrid wyśle temu użytkownikowi krótkotrwały, jednorazowy link na potwierdzony adres e-mail. Administrator nie wybierze ani nie zobaczy nowego hasła. diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx index dac7aafe5..b7d747299 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx @@ -251,4 +251,7 @@ Minst {0} tecken Minst en siffra Minst en stor och en liten bokstav + Skicka e-post för lösenordsåterställning + Skicka e-post för lösenordsåterställning + Resgrid skickar en kortlivad engångslänk till användarens bekräftade e-postadress. Administratören väljer eller ser inte det nya lösenordet. diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx index a58e12077..51086b99d 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx @@ -251,4 +251,7 @@ Щонайменше {0} символів Щонайменше одна цифра Щонайменше одна велика та одна мала літера + Надіслати електронний лист для скидання пароля + Надіслати електронний лист для скидання пароля + Resgrid надішле цьому користувачеві короткочасне одноразове посилання на підтверджену електронну адресу. Адміністратор не вибиратиме й не бачитиме новий пароль. diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index fd19d4e41..c3c1bfb39 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -191,6 +191,8 @@ public enum AuditLogTypes ModerationReportSubmitted, ModerationRequestReopened, ModerationRequestCompleted, - ModerationEvidenceDownloaded + ModerationEvidenceDownloaded, + PasswordResetByAdministrator, + UserAuthenticationSessionsRevoked } } diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs index 08ed247ad..0a1040d04 100644 --- a/Core/Resgrid.Model/DepartmentSettingTypes.cs +++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs @@ -74,5 +74,11 @@ public enum DepartmentSettingTypes /// before the board highlights it. /// UnitStatusThresholds = 62, + + /// + /// When enabled, department and group administrators cannot choose a member's new password. + /// Their reset action sends the member the hardened, single-use password recovery link instead. + /// + RequirePasswordResetViaEmail = 63, } } diff --git a/Core/Resgrid.Model/ExternalIdentityLinkMethod.cs b/Core/Resgrid.Model/ExternalIdentityLinkMethod.cs new file mode 100644 index 000000000..20c2840c0 --- /dev/null +++ b/Core/Resgrid.Model/ExternalIdentityLinkMethod.cs @@ -0,0 +1,11 @@ +namespace Resgrid.Model +{ + public enum ExternalIdentityLinkMethod + { + Subject = 0, + VerifiedEmail = 1, + TrustedSamlEmail = 2, + Scim = 3, + Administrator = 4 + } +} diff --git a/Core/Resgrid.Model/Identity/IdentityUser.cs b/Core/Resgrid.Model/Identity/IdentityUser.cs index 57799401a..262fcfd19 100644 --- a/Core/Resgrid.Model/Identity/IdentityUser.cs +++ b/Core/Resgrid.Model/Identity/IdentityUser.cs @@ -131,6 +131,21 @@ public IdentityUser(string userName) : this() //[ProtoMember(15)] public override int AccessFailedCount { get; set; } + /// + /// Monotonically increases whenever all credentials for this account must be invalidated. + /// Existing accounts begin at zero so installing the session schema does not sign them out. + /// + public long AuthenticationGeneration { get; set; } + + /// + /// Credentials issued at or before this UTC instant are invalid. This is also the + /// compatibility boundary for cookies and tokens issued before session tracking existed. + /// + public DateTime? CredentialsValidAfterUtc { get; set; } + + /// UTC timestamp of the latest account-wide authentication state change. + public DateTime? AuthenticationStateChangedOn { get; set; } + [System.ComponentModel.DataAnnotations.Schema.NotMapped] public string UserId { diff --git a/Core/Resgrid.Model/Providers/IEmailProvider.cs b/Core/Resgrid.Model/Providers/IEmailProvider.cs index 33f983ecb..5d811d5f0 100644 --- a/Core/Resgrid.Model/Providers/IEmailProvider.cs +++ b/Core/Resgrid.Model/Providers/IEmailProvider.cs @@ -7,8 +7,10 @@ public interface IEmailProvider { void Configure(object sender, string fromAddress); - Task SendWelcomeMail(string name, string departmentName, string userName, string password, string email, int departmentId); - Task SendPasswordResetMail(string name, string password, string userName, string email, string departmentName); + Task SendWelcomeMail(string name, string departmentName, string userName, string email, int departmentId); + Task SendPasswordRecoveryMail(string name, string email, string departmentName, + string resetUrl, string ipAddress, string userAgent, string requestedOn, bool isSsoManaged); + Task SendPasswordChangedByAdministratorMail(string name, string userName, string email, string departmentName); Task SendSignupMail(string name, string departmentName, string email); Task SendMessageMail(string email, string subject, string messageSubject, string messageBody, string senderEmail, string senderName, string sentOn, int messageId); Task SendCallMail(string email, string subject, string title, string priority, string natureOfCall, string mapPage, diff --git a/Core/Resgrid.Model/Repositories/IIdentityRepository.cs b/Core/Resgrid.Model/Repositories/IIdentityRepository.cs index ae12a82ce..0fab23756 100644 --- a/Core/Resgrid.Model/Repositories/IIdentityRepository.cs +++ b/Core/Resgrid.Model/Repositories/IIdentityRepository.cs @@ -37,13 +37,6 @@ public interface IIdentityRepository /// IdentityUser. IdentityUser Update(IdentityUser user); - /// - /// Updates the username. - /// - /// The old username. - /// The new username. - void UpdateUsername(string oldUsername, string newUsername); - /// /// Adds the user to role. /// @@ -111,13 +104,6 @@ public interface IIdentityRepository /// List<UserGroupRole>. Task> GetAllUsersGroupsAndRolesAsync(int departmentId, bool retrieveHidden, bool retrieveDisabled, bool retrieveDeleted); - /// - /// Updates the email. - /// - /// The user identifier. - /// The new email. - void UpdateEmail(string userId, string newEmail); - /// /// Gets the user by user name asynchronous. /// diff --git a/Core/Resgrid.Model/Repositories/IUserExternalIdentityLinksRepository.cs b/Core/Resgrid.Model/Repositories/IUserExternalIdentityLinksRepository.cs new file mode 100644 index 000000000..c3447c2a4 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IUserExternalIdentityLinksRepository.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IUserExternalIdentityLinksRepository : IRepository + { + Task GetActiveBySubjectAsync(string departmentSsoConfigId, string externalSubject); + Task GetActiveByUserAndConfigAsync(string userId, string departmentSsoConfigId); + Task> GetActiveByUserAsync(string userId); + } +} diff --git a/Core/Resgrid.Model/Repositories/IUserSessionsRepository.cs b/Core/Resgrid.Model/Repositories/IUserSessionsRepository.cs new file mode 100644 index 000000000..4e5e87b9b --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IUserSessionsRepository.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IUserSessionsRepository : IRepository + { + Task> GetActiveByUserAsync(string userId, DateTime utcNow); + Task GetByAuthorizationIdAsync(string authorizationId); + Task TouchAsync(string sessionId, DateTime occurredOn, DateTime writeBefore, string ipAddress, + string country, string region, string city, string userAgent, CancellationToken cancellationToken); + Task UpdateDepartmentAsync(string targetUserId, string sessionId, int departmentId, + CancellationToken cancellationToken); + Task RevokeAsync(string targetUserId, string sessionId, string actorUserId, int reason, DateTime revokedOn, CancellationToken cancellationToken); + Task RevokeOthersAsync(string userId, string currentSessionId, int reason, DateTime revokedOn, CancellationToken cancellationToken); + Task RevokeAllAsync(string targetUserId, string actorUserId, int reason, DateTime revokedOn, CancellationToken cancellationToken); + Task RevokeDepartmentAsync(string targetUserId, int departmentId, int reason, DateTime revokedOn, CancellationToken cancellationToken); + Task PurgeInactiveBeforeAsync(DateTime historyBeforeUtc, CancellationToken cancellationToken); + } +} diff --git a/Core/Resgrid.Model/Security/PasswordRecoveryContracts.cs b/Core/Resgrid.Model/Security/PasswordRecoveryContracts.cs new file mode 100644 index 000000000..f51954dc9 --- /dev/null +++ b/Core/Resgrid.Model/Security/PasswordRecoveryContracts.cs @@ -0,0 +1,21 @@ +using System; + +namespace Resgrid.Model.Security +{ + public class PasswordRecoveryRequest + { + public string UserId { get; set; } + public string Email { get; set; } + public long AuthenticationGeneration { get; set; } + public string SecurityStampHash { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ExpiresOn { get; set; } + } + + public class PasswordRecoveryIssueResult + { + public bool Issued { get; set; } + public bool RateLimited { get; set; } + public string Token { get; set; } + } +} diff --git a/Core/Resgrid.Model/Security/SessionClaimTypes.cs b/Core/Resgrid.Model/Security/SessionClaimTypes.cs new file mode 100644 index 000000000..0109e2dff --- /dev/null +++ b/Core/Resgrid.Model/Security/SessionClaimTypes.cs @@ -0,0 +1,9 @@ +namespace Resgrid.Model.Security +{ + public static class SessionClaimTypes + { + public const string SessionId = "sid"; + public const string AuthenticationGeneration = "auth_ver"; + public const string WebEventingOnly = "web_eventing_only"; + } +} diff --git a/Core/Resgrid.Model/Security/SessionCreationDeniedException.cs b/Core/Resgrid.Model/Security/SessionCreationDeniedException.cs new file mode 100644 index 000000000..62a2b816e --- /dev/null +++ b/Core/Resgrid.Model/Security/SessionCreationDeniedException.cs @@ -0,0 +1,15 @@ +using System; + +namespace Resgrid.Model.Security +{ + public sealed class SessionCreationDeniedException : Exception + { + public SessionCreationDeniedException(string failureCode) + : base("The authentication session could not be created.") + { + FailureCode = failureCode; + } + + public string FailureCode { get; } + } +} diff --git a/Core/Resgrid.Model/Security/UserSessionContracts.cs b/Core/Resgrid.Model/Security/UserSessionContracts.cs new file mode 100644 index 000000000..3af244337 --- /dev/null +++ b/Core/Resgrid.Model/Security/UserSessionContracts.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.Security +{ + public class SessionIssueContext + { + public string UserId { get; set; } + public int? DepartmentId { get; set; } + public long AuthenticationGeneration { get; set; } + public UserSessionClientApplication ClientApplication { get; set; } + public string ClientInstanceIdHash { get; set; } + public string DeviceName { get; set; } + public string DeviceType { get; set; } + public string OperatingSystem { get; set; } + public string Browser { get; set; } + public string ApplicationVersion { get; set; } + public UserSessionAuthenticationMethod AuthenticationMethod { get; set; } + public string DepartmentSsoConfigId { get; set; } + public string OpenIddictAuthorizationId { get; set; } + public string WebCookieTicketKey { get; set; } + public DateTime ExpiresOn { get; set; } + public string IpAddress { get; set; } + public string Country { get; set; } + public string Region { get; set; } + public string City { get; set; } + public string UserAgent { get; set; } + public bool IsLegacyAdopted { get; set; } + } + + public class SessionPrincipalContext + { + public string UserId { get; set; } + public string SessionId { get; set; } + public long? AuthenticationGeneration { get; set; } + public int? DepartmentId { get; set; } + public DateTime? CredentialIssuedOn { get; set; } + } + + public class LegacySessionContext : SessionIssueContext + { + public string StableCredentialIdentifier { get; set; } + } + + public class RequestActivity + { + public DateTime OccurredOn { get; set; } + public string IpAddress { get; set; } + public string Country { get; set; } + public string Region { get; set; } + public string City { get; set; } + public string UserAgent { get; set; } + } + + public class ClientSessionMetadata + { + public string DeviceName { get; set; } + public string DeviceType { get; set; } + public string OperatingSystem { get; set; } + public string Browser { get; set; } + public string ApplicationVersion { get; set; } + } + + public class IpLocationResult + { + public string Country { get; set; } + public string Region { get; set; } + public string City { get; set; } + public bool IsKnown => !string.IsNullOrWhiteSpace(Country) || !string.IsNullOrWhiteSpace(Region) || + !string.IsNullOrWhiteSpace(City); + } + + public class SessionValidationResult + { + public bool IsValid { get; set; } + public bool CanAdoptLegacy { get; set; } + public string FailureCode { get; set; } + public UserSession Session { get; set; } + + public static SessionValidationResult Valid(UserSession session = null, bool canAdoptLegacy = false) => + new SessionValidationResult { IsValid = true, CanAdoptLegacy = canAdoptLegacy, Session = session }; + + public static SessionValidationResult Invalid(string code) => + new SessionValidationResult { IsValid = false, FailureCode = code }; + } + + public class UserSessionSummary + { + public string UserSessionId { get; set; } + public int? DepartmentId { get; set; } + public UserSessionState State { get; set; } + public UserSessionClientApplication ClientApplication { get; set; } + public string DeviceName { get; set; } + public string DeviceType { get; set; } + public string OperatingSystem { get; set; } + public string Browser { get; set; } + public string ApplicationVersion { get; set; } + public UserSessionAuthenticationMethod AuthenticationMethod { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime LastActiveOn { get; set; } + public DateTime ExpiresOn { get; set; } + public string LastIpAddress { get; set; } + public string LastCountry { get; set; } + public string LastRegion { get; set; } + public string LastCity { get; set; } + public string UserAgent { get; set; } + public bool IsLegacyAdopted { get; set; } + public bool IsCurrent { get; set; } + } + + public class RevocationResult + { + public int RevokedSessionCount { get; set; } + public DateTime RevokedOn { get; set; } + } + + public class SsoManagementState + { + public bool IsSsoManaged { get; set; } + public bool IsScimManaged { get; set; } + public bool IsEmailExternallyManaged { get; set; } + public IReadOnlyList ProviderNames { get; set; } = Array.Empty(); + } +} diff --git a/Core/Resgrid.Model/Services/IClientSessionMetadataParser.cs b/Core/Resgrid.Model/Services/IClientSessionMetadataParser.cs new file mode 100644 index 000000000..97d29892a --- /dev/null +++ b/Core/Resgrid.Model/Services/IClientSessionMetadataParser.cs @@ -0,0 +1,10 @@ +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + public interface IClientSessionMetadataParser + { + ClientSessionMetadata Parse(string userAgent, string deviceName = null, string deviceType = null, + string operatingSystem = null, string browser = null, string applicationVersion = null); + } +} diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs index edd6c67c7..11fc47b00 100644 --- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs @@ -374,6 +374,12 @@ Task SetUnitCallStatusOverridesByUnitTypeAsync(int department Task GetModernNotificationsEnabledAsync(int departmentId, bool bypassCache = false); + /// + /// True when administrator-initiated password resets must send the member a single-use email + /// link instead of allowing the administrator to choose the new password. + /// + Task GetRequirePasswordResetViaEmailAsync(int departmentId, bool bypassCache = false); + /// /// True when the department forces every member to use their security PIN for dangerous /// chatbot/SMS actions (overrides the per-user opt-in). diff --git a/Core/Resgrid.Model/Services/IEmailService.cs b/Core/Resgrid.Model/Services/IEmailService.cs index 8215f39bd..651710135 100644 --- a/Core/Resgrid.Model/Services/IEmailService.cs +++ b/Core/Resgrid.Model/Services/IEmailService.cs @@ -19,20 +19,18 @@ public interface IEmailService /// The name. /// The email address. /// Name of the user. - /// The password. /// The department identifier. - Task SendWelcomeEmail(string departmentName, string name, string emailAddress, string userName, string password, + Task SendWelcomeEmail(string departmentName, string name, string emailAddress, string userName, int departmentId); /// - /// Sends the password reset email. + /// Sends a public password recovery email. SSO-managed accounts receive the request details but no link. /// - /// The email address. - /// The name. - /// Name of the user. - /// The password. - /// Name of the department. - Task SendPasswordResetEmail(string emailAddress, string name, string userName, string password, + Task SendPasswordRecoveryEmail(string emailAddress, string name, string departmentName, + string resetUrl, string ipAddress, string userAgent, DateTime requestedOn, bool isSsoManaged); + + /// Notifies a user that a department administrator changed their password. + Task SendPasswordChangedByAdministratorEmail(string emailAddress, string name, string userName, string departmentName); /// diff --git a/Core/Resgrid.Model/Services/IExternalIdentityLinkService.cs b/Core/Resgrid.Model/Services/IExternalIdentityLinkService.cs new file mode 100644 index 000000000..79cace4e4 --- /dev/null +++ b/Core/Resgrid.Model/Services/IExternalIdentityLinkService.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + public interface IExternalIdentityLinkService + { + Task GetBySubjectAsync(string departmentSsoConfigId, string externalSubject, CancellationToken cancellationToken = default); + Task SaveAsync(UserExternalIdentityLink link, CancellationToken cancellationToken = default); + Task GetSsoManagementStateAsync(string userId, CancellationToken cancellationToken = default); + Task IsLocalLoginAllowedAsync(string userId, CancellationToken cancellationToken = default); + Task IsLocalLoginAllowedAsync(string userId, int departmentId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IIpLocationProvider.cs b/Core/Resgrid.Model/Services/IIpLocationProvider.cs new file mode 100644 index 000000000..76764943f --- /dev/null +++ b/Core/Resgrid.Model/Services/IIpLocationProvider.cs @@ -0,0 +1,12 @@ +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + public interface IIpLocationProvider + { + Task GetApproximateLocationAsync(string ipAddress, + CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IPasswordRecoveryService.cs b/Core/Resgrid.Model/Services/IPasswordRecoveryService.cs new file mode 100644 index 000000000..669d780ae --- /dev/null +++ b/Core/Resgrid.Model/Services/IPasswordRecoveryService.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + public interface IPasswordRecoveryService + { + Task IssueAsync(string userId, string email, string ipAddress, + long authenticationGeneration, string securityStamp, + CancellationToken cancellationToken = default); + Task GetAsync(string token, CancellationToken cancellationToken = default); + Task TryConsumeAsync(string token, CancellationToken cancellationToken = default); + Task RemoveAsync(string token, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IUserSessionService.cs b/Core/Resgrid.Model/Services/IUserSessionService.cs new file mode 100644 index 000000000..8d80199b7 --- /dev/null +++ b/Core/Resgrid.Model/Services/IUserSessionService.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + public interface IUserSessionService + { + Task CreateSessionAsync(SessionIssueContext context, CancellationToken cancellationToken = default); + Task ValidateAsync(SessionPrincipalContext context, CancellationToken cancellationToken = default); + Task AdoptLegacyAsync(LegacySessionContext context, CancellationToken cancellationToken = default); + Task TouchAsync(string sessionId, RequestActivity activity, CancellationToken cancellationToken = default); + Task MoveSessionToDepartmentAsync(string userId, string sessionId, int departmentId, + CancellationToken cancellationToken = default); + Task> GetActiveForUserAsync(string userId, CancellationToken cancellationToken = default); + Task RevokeSessionAsync(string actorUserId, string targetUserId, string sessionId, UserSessionRevocationReason reason, CancellationToken cancellationToken = default); + Task RevokeOtherSessionsAsync(string userId, string currentSessionId, UserSessionRevocationReason reason, CancellationToken cancellationToken = default); + Task RevokeAllAsync(string actorUserId, string targetUserId, UserSessionRevocationReason reason, DateTime validAfterUtc, CancellationToken cancellationToken = default); + Task RevokeAllAfterCredentialChangeAsync(string actorUserId, string targetUserId, UserSessionRevocationReason reason, DateTime validAfterUtc, CancellationToken cancellationToken = default); + Task RevokeDepartmentSessionsAsync(string targetUserId, int departmentId, UserSessionRevocationReason reason, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IUsersService.cs b/Core/Resgrid.Model/Services/IUsersService.cs index 156236d0c..560aa9949 100644 --- a/Core/Resgrid.Model/Services/IUsersService.cs +++ b/Core/Resgrid.Model/Services/IUsersService.cs @@ -15,7 +15,6 @@ public interface IUsersService IdentityUser GetUserById(string userId, bool bypassCache = true); Dictionary GetNewUsersCountForLast5Days(); int GetUsersCount(); - Task UpdateUsername(string oldUsername, string newUsername); IdentityUser GetUserByEmail(string emailAddress); IdentityUser GetMembershipByUserId(string userId); void AddUserToAffiliteRole(string userId); @@ -23,7 +22,6 @@ public interface IUsersService IdentityUser SaveUser(IdentityUser user); void InitUserExtInfo(string userId); Task> GetUserGroupAndRolesByDepartmentIdAsync(int deparmentId, bool retrieveHidden, bool retrieveDisabled, bool retrieveDeleted); - IdentityUser UpdateEmail(string userId, string newEmail); Task DoesUserHaveAnyActiveDepartments(string userName); void ClearCacheForDepartment(int departmentId); Task GetUserByNameAsync(string userName); diff --git a/Core/Resgrid.Model/SystemAudit.cs b/Core/Resgrid.Model/SystemAudit.cs index d984f588b..f1deeeb45 100644 --- a/Core/Resgrid.Model/SystemAudit.cs +++ b/Core/Resgrid.Model/SystemAudit.cs @@ -28,6 +28,12 @@ public class SystemAudit : IEntity public DateTime LoggedOn { get; set; } + public string TargetUserId { get; set; } + + public string SessionId { get; set; } + + public string CorrelationId { get; set; } + public object IdValue { get { return SystemAuditId; } diff --git a/Core/Resgrid.Model/SystemAuditSystems.cs b/Core/Resgrid.Model/SystemAuditSystems.cs index 7e896231d..e9822afd3 100644 --- a/Core/Resgrid.Model/SystemAuditSystems.cs +++ b/Core/Resgrid.Model/SystemAuditSystems.cs @@ -4,6 +4,7 @@ public enum SystemAuditSystems { Website = 0, Api = 1, - Worker = 2 + Worker = 2, + Console = 3 } } diff --git a/Core/Resgrid.Model/SystemAuditTypes.cs b/Core/Resgrid.Model/SystemAuditTypes.cs index d56f6e7b8..d8d0f4e98 100644 --- a/Core/Resgrid.Model/SystemAuditTypes.cs +++ b/Core/Resgrid.Model/SystemAuditTypes.cs @@ -15,6 +15,17 @@ public enum SystemAuditTypes ScimOperation = 10, AccountDeletionRequested = 11, GdprDataExportRequested = 12, - GdprDataExportDownloaded = 13 + GdprDataExportDownloaded = 13, + PasswordChanged = 14, + PasswordResetByAdministrator = 15, + PublicPasswordResetCompleted = 16, + UsernameChanged = 17, + EmailChanged = 18, + SessionRevoked = 19, + OtherSessionsRevoked = 20, + AllSessionsRevoked = 21, + ExternalIdentityLinked = 22, + ExternalIdentityUnlinked = 23, + PasswordResetLinkSentByAdministrator = 24 } } diff --git a/Core/Resgrid.Model/UserExternalIdentityLink.cs b/Core/Resgrid.Model/UserExternalIdentityLink.cs new file mode 100644 index 000000000..e559a99c6 --- /dev/null +++ b/Core/Resgrid.Model/UserExternalIdentityLink.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + [Table("UserExternalIdentityLinks")] + public class UserExternalIdentityLink : IEntity + { + [Key] + [MaxLength(128)] + public string UserExternalIdentityLinkId { get; set; } + [Required, MaxLength(128)] public string UserId { get; set; } + public int DepartmentId { get; set; } + public int DepartmentMemberId { get; set; } + [Required, MaxLength(128)] public string DepartmentSsoConfigId { get; set; } + public int ProviderType { get; set; } + [Required, MaxLength(1024)] public string Issuer { get; set; } + [Required, MaxLength(512)] public string ExternalSubject { get; set; } + public int LinkMethod { get; set; } + [MaxLength(512)] public string EmailAtLink { get; set; } + public bool IsEmailExternallyManaged { get; set; } + public bool IsActive { get; set; } + public DateTime LinkedOn { get; set; } + public DateTime? LastLoginOn { get; set; } + public DateTime? UnlinkedOn { get; set; } + [MaxLength(128)] public string UnlinkedByUserId { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get => UserExternalIdentityLinkId; + set => UserExternalIdentityLinkId = value?.ToString(); + } + + [NotMapped] public string TableName => "UserExternalIdentityLinks"; + [NotMapped] public string IdName => "UserExternalIdentityLinkId"; + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/UserSession.cs b/Core/Resgrid.Model/UserSession.cs new file mode 100644 index 000000000..4e4e94f2c --- /dev/null +++ b/Core/Resgrid.Model/UserSession.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + [Table("UserSessions")] + public class UserSession : IEntity + { + [Key] + [MaxLength(128)] + public string UserSessionId { get; set; } + + [Required] + [MaxLength(128)] + public string UserId { get; set; } + + public int? DepartmentId { get; set; } + public long AuthenticationGeneration { get; set; } + public int State { get; set; } + public long StateVersion { get; set; } + public int ClientApplication { get; set; } + + [MaxLength(128)] public string ClientInstanceIdHash { get; set; } + [MaxLength(256)] public string DeviceName { get; set; } + [MaxLength(128)] public string DeviceType { get; set; } + [MaxLength(128)] public string OperatingSystem { get; set; } + [MaxLength(128)] public string Browser { get; set; } + [MaxLength(64)] public string ApplicationVersion { get; set; } + public int AuthenticationMethod { get; set; } + [MaxLength(128)] public string DepartmentSsoConfigId { get; set; } + [MaxLength(128)] public string OpenIddictAuthorizationId { get; set; } + [MaxLength(512)] public string WebCookieTicketKey { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime LastActiveOn { get; set; } + public DateTime ExpiresOn { get; set; } + [MaxLength(64)] public string FirstIpAddress { get; set; } + [MaxLength(64)] public string LastIpAddress { get; set; } + [MaxLength(128)] public string LastCountry { get; set; } + [MaxLength(128)] public string LastRegion { get; set; } + [MaxLength(128)] public string LastCity { get; set; } + [MaxLength(1024)] public string UserAgent { get; set; } + public bool IsLegacyAdopted { get; set; } + public DateTime? RevokedOn { get; set; } + [MaxLength(128)] public string RevokedByUserId { get; set; } + public int? RevocationReason { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get => UserSessionId; + set => UserSessionId = value?.ToString(); + } + + [NotMapped] public string TableName => "UserSessions"; + [NotMapped] public string IdName => "UserSessionId"; + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/UserSessionAuthenticationMethod.cs b/Core/Resgrid.Model/UserSessionAuthenticationMethod.cs new file mode 100644 index 000000000..1d2801d5a --- /dev/null +++ b/Core/Resgrid.Model/UserSessionAuthenticationMethod.cs @@ -0,0 +1,11 @@ +namespace Resgrid.Model +{ + public enum UserSessionAuthenticationMethod + { + LegacyUnknown = 0, + LocalPassword = 1, + OidcSso = 2, + SamlSso = 3, + Recovery = 4 + } +} diff --git a/Core/Resgrid.Model/UserSessionClientApplication.cs b/Core/Resgrid.Model/UserSessionClientApplication.cs new file mode 100644 index 000000000..3137c6e5a --- /dev/null +++ b/Core/Resgrid.Model/UserSessionClientApplication.cs @@ -0,0 +1,15 @@ +namespace Resgrid.Model +{ + public enum UserSessionClientApplication + { + UnknownLegacy = 0, + Web = 1, + Responder = 2, + Unit = 3, + Dispatch = 4, + BigBoard = 5, + Command = 6, + Mcp = 7, + Api = 8 + } +} diff --git a/Core/Resgrid.Model/UserSessionRevocationReason.cs b/Core/Resgrid.Model/UserSessionRevocationReason.cs new file mode 100644 index 000000000..d74b177d7 --- /dev/null +++ b/Core/Resgrid.Model/UserSessionRevocationReason.cs @@ -0,0 +1,20 @@ +namespace Resgrid.Model +{ + public enum UserSessionRevocationReason + { + UserRevoked = 0, + OtherSessionsRevoked = 1, + PasswordChanged = 2, + PasswordReset = 3, + UsernameChanged = 4, + EmailChanged = 5, + AccountCompromised = 6, + AdministratorRevoked = 7, + MembershipDisabled = 8, + SsoIdentityUnlinked = 9, + ConcurrentSessionLimit = 10, + Expired = 11, + LoggedOut = 12, + AccountDeactivated = 13 + } +} diff --git a/Core/Resgrid.Model/UserSessionState.cs b/Core/Resgrid.Model/UserSessionState.cs new file mode 100644 index 000000000..a9a07ccdf --- /dev/null +++ b/Core/Resgrid.Model/UserSessionState.cs @@ -0,0 +1,9 @@ +namespace Resgrid.Model +{ + public enum UserSessionState + { + Active = 0, + Revoked = 1, + Expired = 2 + } +} diff --git a/Core/Resgrid.Services/ClientSessionMetadataParser.cs b/Core/Resgrid.Services/ClientSessionMetadataParser.cs new file mode 100644 index 000000000..b7ea4c395 --- /dev/null +++ b/Core/Resgrid.Services/ClientSessionMetadataParser.cs @@ -0,0 +1,83 @@ +using System; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// Small, dependency-free parser for actionable session labels. Explicit app metadata wins; + /// browser User-Agent parsing is a bounded fallback and is never used for authorization. + /// + public class ClientSessionMetadataParser : IClientSessionMetadataParser + { + public ClientSessionMetadata Parse(string userAgent, string deviceName = null, string deviceType = null, + string operatingSystem = null, string browser = null, string applicationVersion = null) + { + var agent = userAgent ?? string.Empty; + var parsedOs = First(operatingSystem, ParseOperatingSystem(agent)); + var parsedType = First(deviceType, ParseDeviceType(agent)); + return new ClientSessionMetadata + { + DeviceName = First(deviceName, DefaultDeviceName(parsedType, parsedOs)), + DeviceType = parsedType, + OperatingSystem = parsedOs, + Browser = First(browser, ParseBrowser(agent)), + ApplicationVersion = applicationVersion + }; + } + + private static string ParseBrowser(string agent) + { + if (agent.Contains("Edg/", StringComparison.OrdinalIgnoreCase)) return Token(agent, "Edg/", "Edge"); + if (agent.Contains("OPR/", StringComparison.OrdinalIgnoreCase)) return Token(agent, "OPR/", "Opera"); + if (agent.Contains("CriOS/", StringComparison.OrdinalIgnoreCase)) return Token(agent, "CriOS/", "Chrome"); + if (agent.Contains("Chrome/", StringComparison.OrdinalIgnoreCase)) return Token(agent, "Chrome/", "Chrome"); + if (agent.Contains("FxiOS/", StringComparison.OrdinalIgnoreCase)) return Token(agent, "FxiOS/", "Firefox"); + if (agent.Contains("Firefox/", StringComparison.OrdinalIgnoreCase)) return Token(agent, "Firefox/", "Firefox"); + if (agent.Contains("Safari/", StringComparison.OrdinalIgnoreCase) && + agent.Contains("Version/", StringComparison.OrdinalIgnoreCase)) return Token(agent, "Version/", "Safari"); + return string.IsNullOrWhiteSpace(agent) ? null : "Other client"; + } + + private static string ParseOperatingSystem(string agent) + { + if (agent.Contains("Windows NT 10.0", StringComparison.OrdinalIgnoreCase)) return "Windows 10/11"; + if (agent.Contains("Windows", StringComparison.OrdinalIgnoreCase)) return "Windows"; + if (agent.Contains("iPhone", StringComparison.OrdinalIgnoreCase)) return "iOS"; + if (agent.Contains("iPad", StringComparison.OrdinalIgnoreCase)) return "iPadOS"; + if (agent.Contains("Android", StringComparison.OrdinalIgnoreCase)) return "Android"; + if (agent.Contains("Mac OS X", StringComparison.OrdinalIgnoreCase)) return "macOS"; + if (agent.Contains("Linux", StringComparison.OrdinalIgnoreCase)) return "Linux"; + return null; + } + + private static string ParseDeviceType(string agent) + { + if (agent.Contains("iPad", StringComparison.OrdinalIgnoreCase) || + (agent.Contains("Android", StringComparison.OrdinalIgnoreCase) && + !agent.Contains("Mobile", StringComparison.OrdinalIgnoreCase))) return "Tablet"; + if (agent.Contains("Mobile", StringComparison.OrdinalIgnoreCase) || + agent.Contains("iPhone", StringComparison.OrdinalIgnoreCase)) return "Phone"; + return string.IsNullOrWhiteSpace(agent) ? null : "Computer"; + } + + private static string DefaultDeviceName(string type, string operatingSystem) + { + if (string.IsNullOrWhiteSpace(type)) return null; + return string.IsNullOrWhiteSpace(operatingSystem) ? type : $"{operatingSystem} {type}"; + } + + private static string Token(string agent, string marker, string name) + { + var start = agent.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (start < 0) return name; + start += marker.Length; + var end = agent.IndexOfAny(new[] {' ', ';', ')'}, start); + var version = agent.Substring(start, (end < 0 ? agent.Length : end) - start); + return string.IsNullOrWhiteSpace(version) ? name : $"{name} {version}"; + } + + private static string First(string supplied, string fallback) => + string.IsNullOrWhiteSpace(supplied) ? fallback : supplied; + } +} diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs index 99046810f..b1cbcd500 100644 --- a/Core/Resgrid.Services/CommunicationService.cs +++ b/Core/Resgrid.Services/CommunicationService.cs @@ -55,7 +55,9 @@ public async Task SendMessageAsync(Message message, string sendersName, st if (profile == null && !String.IsNullOrWhiteSpace(message.ReceivingUserId)) profile = await _userProfileService.GetProfileByUserIdAsync(message.ReceivingUserId); - if (profile == null || (message.SystemGenerated ? profile.SendNotificationSms : profile.SendMessageSms)) + // Weather Alert Notifications are intentionally email/push-only. + if (message.Type != (int)MessageTypes.WeatherAlert && + (profile == null || (message.SystemGenerated ? profile.SendNotificationSms : profile.SendMessageSms))) { if (profile == null || profile.MobileNumberVerified.IsContactMethodAllowedForSending()) { @@ -126,21 +128,26 @@ public async Task SendMessageAsync(Message message, string sendersName, st } - // Outbound chat platforms (Discord/Slack/etc.) as a sibling channel; failures are isolated. - try - { - await _chatbotOutboundService.SendToUserAsync(message.ReceivingUserId, departmentId, - new ChatbotOutboundMessage - { - Type = ChatbotOutboundType.Message, - Title = message.Subject, - Body = message.Body, - ReferenceId = message.MessageId.ToString() - }); - } - catch (Exception ex) + // Weather alerts already use the user's notification channels; mirroring them here also + // persists them as unsolicited messages in the Resgrid Assistant conversation. + if (message.Type != (int)MessageTypes.WeatherAlert) { - Logging.LogException(ex); + // Outbound chat platforms (Discord/Slack/etc.) as a sibling channel; failures are isolated. + try + { + await _chatbotOutboundService.SendToUserAsync(message.ReceivingUserId, departmentId, + new ChatbotOutboundMessage + { + Type = ChatbotOutboundType.Message, + Title = message.Subject, + Body = message.Body, + ReferenceId = message.MessageId.ToString() + }); + } + catch (Exception ex) + { + Logging.LogException(ex); + } } return true; diff --git a/Core/Resgrid.Services/DeleteService.cs b/Core/Resgrid.Services/DeleteService.cs index 805209e69..5821a5510 100644 --- a/Core/Resgrid.Services/DeleteService.cs +++ b/Core/Resgrid.Services/DeleteService.cs @@ -39,6 +39,7 @@ public class DeleteService : IDeleteService private readonly IDeleteRepository _deleteRepository; private readonly IAuditLogsRepository _auditLogsRepository; private readonly IScheduledTasksService _scheduledTasksService; + private readonly IUserSessionService _userSessionService; public DeleteService(IAuthorizationService authorizationService, IDepartmentsService departmentsService, ICallsService callsService, IActionLogsService actionLogsService, IUsersService usersService, @@ -47,7 +48,8 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer IDistributionListsService distributionListsService, IShiftsService shiftsService, IUnitsService unitsService, ICertificationService certificationService, ILogService logService, IInventoryService inventoryService, IEventAggregator eventAggregator, IAddressService addressService, IQueueService queueService, IEmailService emailService, - IDeleteRepository deleteRepository, IAuditLogsRepository auditLogsRepository, IScheduledTasksService scheduledTasksService) + IDeleteRepository deleteRepository, IAuditLogsRepository auditLogsRepository, + IScheduledTasksService scheduledTasksService, IUserSessionService userSessionService) { _authorizationService = authorizationService; _departmentsService = departmentsService; @@ -73,6 +75,7 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer _deleteRepository = deleteRepository; _auditLogsRepository = auditLogsRepository; _scheduledTasksService = scheduledTasksService; + _userSessionService = userSessionService; } public async Task DeleteUserAsync(int departmentId, string authorizingUserId, string userIdToDelete, CancellationToken cancellationToken = default(CancellationToken)) @@ -115,6 +118,9 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer // Soft-delete the membership last (this also writes the audit event and clears caches). var member = await _departmentsService.DeleteUserAsync(departmentId, userId, revokingUserId, cancellationToken); + if (member != null && member.IsDeleted) + await _userSessionService.RevokeDepartmentSessionsAsync(userId, departmentId, + UserSessionRevocationReason.MembershipDisabled, cancellationToken); return member != null && member.IsDeleted; } @@ -229,6 +235,8 @@ private async Task DeactivateUserAccountCoreAsync(string user await _userProfileService.SaveProfileAsync(departmentId, userProfile, cancellationToken); } + await _userSessionService.RevokeAllAsync(userIdToDelete, userIdToDelete, + UserSessionRevocationReason.AccountDeactivated, System.DateTime.UtcNow, cancellationToken); await _usersService.ClearOutUserLoginAsync(userIdToDelete); return DeleteUserResults.NoFailure; diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index 9f76b5743..da24245b1 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -24,6 +24,7 @@ public class DepartmentSettingsService : IDepartmentSettingsService private static string TtsLanguageCacheKey = "DSetTtsLanguage_{0}"; private static string PersonnelOnUnitSetUnitStatusCacheKey = "DSetPersonnelOnUnitSetUnitStatus_{0}"; private static string ModernNotificationsCacheKey = "DSetModernNotifications_{0}"; + private static string RequirePasswordResetViaEmailCacheKey = "DSetRequirePasswordResetViaEmail_{0}"; private static string ForceChatbotSecurityPinCacheKey = "DSetForceChatbotSecurityPin_{0}"; private static string HardwareTrackingStaleAfterSecondsCacheKey = "DSetHardwareTrackingStale_{0}"; private static string HardwareTrackingMobileFallbackCacheKey = "DSetHardwareTrackingFallback_{0}"; @@ -1236,6 +1237,25 @@ async Task getSetting() return bool.Parse(await getSetting()); } + public async Task GetRequirePasswordResetViaEmailAsync(int departmentId, bool bypassCache = false) + { + async Task getSetting() + { + var setting = await GetSettingByDepartmentIdType( + departmentId, DepartmentSettingTypes.RequirePasswordResetViaEmail); + return setting?.Setting ?? "false"; + } + + var value = Config.SystemBehaviorConfig.CacheEnabled && !bypassCache + ? await _cacheProvider.RetrieveAsync( + string.Format(RequirePasswordResetViaEmailCacheKey, departmentId), + getSetting, + LongCacheLength) + : await getSetting(); + + return bool.TryParse(value, out var enabled) && enabled; + } + public async Task GetForceChatbotSecurityPinAsync(int departmentId, bool bypassCache = false) { async Task getSetting() @@ -1346,6 +1366,9 @@ private async Task InvalidateSettingCacheAsync(int departmentId, DepartmentSetti case DepartmentSettingTypes.EnableModernNotifications: cacheKey = string.Format(ModernNotificationsCacheKey, departmentId); break; + case DepartmentSettingTypes.RequirePasswordResetViaEmail: + cacheKey = string.Format(RequirePasswordResetViaEmailCacheKey, departmentId); + break; case DepartmentSettingTypes.ForceChatbotSecurityPin: cacheKey = string.Format(ForceChatbotSecurityPinCacheKey, departmentId); break; diff --git a/Core/Resgrid.Services/DepartmentSsoService.cs b/Core/Resgrid.Services/DepartmentSsoService.cs index ce24f7799..d730e6fb3 100644 --- a/Core/Resgrid.Services/DepartmentSsoService.cs +++ b/Core/Resgrid.Services/DepartmentSsoService.cs @@ -41,6 +41,7 @@ public class DepartmentSsoService : IDepartmentSsoService private readonly IUserProfileService _userProfileService; private readonly IEncryptionService _encryptionService; private readonly ICacheProvider _cacheProvider; + private readonly IExternalIdentityLinkService _externalIdentityLinkService; public DepartmentSsoService( IDepartmentSsoConfigRepository ssoConfigRepository, @@ -49,7 +50,8 @@ public DepartmentSsoService( IDepartmentsService departmentsService, IUserProfileService userProfileService, IEncryptionService encryptionService, - ICacheProvider cacheProvider) + ICacheProvider cacheProvider, + IExternalIdentityLinkService externalIdentityLinkService) { _ssoConfigRepository = ssoConfigRepository; _securityPolicyRepository = securityPolicyRepository; @@ -58,6 +60,7 @@ public DepartmentSsoService( _userProfileService = userProfileService; _encryptionService = encryptionService; _cacheProvider = cacheProvider; + _externalIdentityLinkService = externalIdentityLinkService; } // ── SSO Config CRUD ─────────────────────────────────────────────────── @@ -167,12 +170,10 @@ public async Task ValidateExternalTokenAsync(int departmentId, public async Task ProvisionOrLinkUserAsync(int departmentId, ClaimsPrincipal externalClaims, DepartmentSsoConfig config, string departmentCode, CancellationToken cancellationToken = default) { - if (externalClaims == null || config == null) + if (externalClaims == null || config == null || config.DepartmentId != departmentId) return null; - // Resolve attribute mapping var mapping = ResolveAttributeMapping(config.AttributeMappingJson); - var email = GetMappedClaim(externalClaims, mapping, "email", ClaimTypes.Email, "email", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"); var externalSubject = GetMappedClaim(externalClaims, mapping, "subject", @@ -182,51 +183,117 @@ public async Task ProvisionOrLinkUserAsync(int departmentId, Claim var lastName = GetMappedClaim(externalClaims, mapping, "lastName", ClaimTypes.Surname, "family_name", "surname"); - if (string.IsNullOrWhiteSpace(email) && string.IsNullOrWhiteSpace(externalSubject)) + // A mutable email address is never accepted as the durable external identifier. + if (string.IsNullOrWhiteSpace(externalSubject)) return null; - // Try to find existing member by ExternalSsoId first, then by email - DepartmentMember existingMember = null; - var departmentMembers = await _departmentMembersRepository.GetAllDepartmentMembersUnlimitedAsync(departmentId); + var now = DateTime.UtcNow; + var members = await _departmentMembersRepository.GetAllDepartmentMembersUnlimitedAsync(departmentId); + var link = await _externalIdentityLinkService.GetBySubjectAsync(config.DepartmentSsoConfigId, + externalSubject, cancellationToken); + DepartmentMember member = null; + var linkMethod = ExternalIdentityLinkMethod.Subject; - if (!string.IsNullOrWhiteSpace(externalSubject)) - existingMember = departmentMembers?.FirstOrDefault(m => m.ExternalSsoId == externalSubject); + if (link != null) + { + if (link.DepartmentId != departmentId) + return null; + member = members?.FirstOrDefault(candidate => candidate.UserId == link.UserId); + } - if (existingMember == null && !string.IsNullOrWhiteSpace(email)) + // Compatibility for accounts linked before the durable binding table existed. + member ??= members?.FirstOrDefault(candidate => candidate.ExternalSsoId == externalSubject); + + // Bootstrap-by-email is permitted only when the signed IdP assertion explicitly + // marks the email as verified. SAML deployments without such a claim require an + // administrator-created/SCIM link instead of silently taking over an email match. + if (member == null && !string.IsNullOrWhiteSpace(email) && IsVerifiedEmail(externalClaims)) { - // Attempt to find by email via department users - var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); var users = await _departmentsService.GetAllUsersForDepartment(departmentId, false, true); - var matchedUser = users?.FirstOrDefault(u => string.Equals(u.Email, email, StringComparison.OrdinalIgnoreCase)); - + var matchedUser = users?.FirstOrDefault(candidate => + string.Equals(candidate.Email, email, StringComparison.OrdinalIgnoreCase)); if (matchedUser != null) - existingMember = departmentMembers?.FirstOrDefault(m => m.UserId == matchedUser.Id); + { + member = members?.FirstOrDefault(candidate => candidate.UserId == matchedUser.Id); + linkMethod = ExternalIdentityLinkMethod.VerifiedEmail; + } } - if (existingMember != null) + if (member != null) { - // Link / update the existing member - if (string.IsNullOrWhiteSpace(existingMember.ExternalSsoId) && !string.IsNullOrWhiteSpace(externalSubject)) + if (string.IsNullOrWhiteSpace(member.ExternalSsoId)) + { + member.ExternalSsoId = externalSubject; + member.SsoLinkedOn = now; + } + else if (!string.Equals(member.ExternalSsoId, externalSubject, StringComparison.Ordinal)) { - existingMember.ExternalSsoId = externalSubject; - existingMember.SsoLinkedOn = DateTime.UtcNow; + return null; } - existingMember.LastSsoLoginOn = DateTime.UtcNow; - await _departmentMembersRepository.SaveOrUpdateAsync(existingMember, cancellationToken); + member.LastSsoLoginOn = now; + await _departmentMembersRepository.SaveOrUpdateAsync(member, cancellationToken); + await SaveExternalLinkAsync(link, member, config, externalClaims, externalSubject, email, + linkMethod, linkMethod == ExternalIdentityLinkMethod.VerifiedEmail, now, cancellationToken); var users = await _departmentsService.GetAllUsersForDepartment(departmentId, false, true); - return users?.FirstOrDefault(u => u.Id == existingMember.UserId); + return users?.FirstOrDefault(candidate => candidate.Id == member.UserId); } - // Auto-provision if enabled - if (!config.AutoProvisionUsers) + if (!config.AutoProvisionUsers || string.IsNullOrWhiteSpace(email)) return null; - var provisionedUser = await ProvisionNewUserAsync(departmentId, email, firstName, lastName, externalSubject, config, departmentCode, cancellationToken); + var provisionedUser = await ProvisionNewUserAsync(departmentId, email, firstName, lastName, + externalSubject, config, departmentCode, cancellationToken); + if (provisionedUser != null) + { + var provisionedMember = await _departmentsService.GetDepartmentMemberAsync(provisionedUser.Id, departmentId); + if (provisionedMember != null) + await SaveExternalLinkAsync(null, provisionedMember, config, externalClaims, externalSubject, + email, ExternalIdentityLinkMethod.Subject, true, now, cancellationToken); + } + return provisionedUser; } + private async Task SaveExternalLinkAsync(UserExternalIdentityLink link, DepartmentMember member, + DepartmentSsoConfig config, ClaimsPrincipal externalClaims, string externalSubject, string email, + ExternalIdentityLinkMethod linkMethod, bool emailExternallyManaged, DateTime now, + CancellationToken cancellationToken) + { + link ??= new UserExternalIdentityLink + { + UserId = member.UserId, + DepartmentId = config.DepartmentId, + DepartmentMemberId = member.DepartmentMemberId, + DepartmentSsoConfigId = config.DepartmentSsoConfigId, + ProviderType = config.SsoProviderType, + Issuer = GetExternalIssuer(externalClaims, config), + ExternalSubject = externalSubject, + EmailAtLink = email, + LinkMethod = (int)linkMethod, + IsEmailExternallyManaged = emailExternallyManaged, + LinkedOn = now + }; + + link.LastLoginOn = now; + await _externalIdentityLinkService.SaveAsync(link, cancellationToken); + } + + private static bool IsVerifiedEmail(ClaimsPrincipal principal) + { + var value = principal.Claims.FirstOrDefault(claim => + string.Equals(claim.Type, "email_verified", StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, "http://schemas.openid.net/claim/email_verified", StringComparison.OrdinalIgnoreCase))?.Value; + return bool.TryParse(value, out var verified) && verified; + } + + private static string GetExternalIssuer(ClaimsPrincipal principal, DepartmentSsoConfig config) => + principal.Claims.FirstOrDefault(claim => string.Equals(claim.Type, "iss", StringComparison.OrdinalIgnoreCase))?.Value + ?? config.Authority + ?? config.EntityId + ?? $"department-sso:{config.DepartmentSsoConfigId}"; + // ── Policy Enforcement ──────────────────────────────────────────────── public async Task EnforceSecurityPolicyAsync(int departmentId, string userId, string clientIpAddress, bool mfaCompleted, bool loginViaSso, CancellationToken cancellationToken = default) @@ -282,7 +349,7 @@ public async Task ValidateScimBearerTokenAsync(int departmentId, string be return false; var storedToken = _encryptionService.DecryptForDepartment(scimConfig.EncryptedScimBearerToken, departmentId, departmentCode); - return string.Equals(storedToken, bearerToken, StringComparison.Ordinal); + return FixedTimeSecretEquals(storedToken, bearerToken); } catch (Exception ex) { @@ -315,7 +382,7 @@ public async Task ValidateScimBearerTokenAsync(int departmentId, string be var storedToken = _encryptionService.DecryptForDepartment( scimConfig.EncryptedScimBearerToken, claimedDepartmentId, departmentCode); - if (!string.Equals(storedToken, bearerToken, StringComparison.Ordinal)) + if (!FixedTimeSecretEquals(storedToken, bearerToken)) return null; // Double-check: the config's own DepartmentId must equal the claimed ID. @@ -849,6 +916,17 @@ private async Task ProvisionNewUserAsync(int departmentId, string } } + private static bool FixedTimeSecretEquals(string stored, string provided) + { + if (stored == null || provided == null) + return false; + + var storedBytes = Encoding.UTF8.GetBytes(stored); + var providedBytes = Encoding.UTF8.GetBytes(provided); + return storedBytes.Length == providedBytes.Length && + CryptographicOperations.FixedTimeEquals(storedBytes, providedBytes); + } + private static bool IsIpAddressAllowed(string clientIp, string allowedRangesCsv) { if (string.IsNullOrWhiteSpace(clientIp)) diff --git a/Core/Resgrid.Services/EmailService.cs b/Core/Resgrid.Services/EmailService.cs index d52dfb486..ba3ca1409 100644 --- a/Core/Resgrid.Services/EmailService.cs +++ b/Core/Resgrid.Services/EmailService.cs @@ -62,11 +62,11 @@ public EmailService(IUserProfileService userProfileService, IUsersService usersS } #endregion Private Members and Constructors - public async Task SendWelcomeEmail(string departmentName, string name, string emailAddress, string userName, string password, int departmentId) + public async Task SendWelcomeEmail(string departmentName, string name, string emailAddress, string userName, int departmentId) { try { - await _emailProvider.SendWelcomeMail(name, departmentName, userName, password, emailAddress, departmentId); + await _emailProvider.SendWelcomeMail(name, departmentName, userName, emailAddress, departmentId); return true; } catch (Exception ex) @@ -77,11 +77,30 @@ public async Task SendWelcomeEmail(string departmentName, string name, str return false; } - public async Task SendPasswordResetEmail(string emailAddress, string name, string userName, string password, string departmentName) + public async Task SendPasswordRecoveryEmail(string emailAddress, string name, + string departmentName, string resetUrl, string ipAddress, string userAgent, DateTime requestedOn, + bool isSsoManaged) { try { - await _emailProvider.SendPasswordResetMail(name, password, userName, emailAddress, departmentName); + await _emailProvider.SendPasswordRecoveryMail(name, emailAddress, departmentName, + resetUrl, ipAddress, userAgent, requestedOn.ToString("u"), isSsoManaged); + return true; + } + catch (Exception ex) + { + Logging.LogException(ex); + } + + return false; + } + + public async Task SendPasswordChangedByAdministratorEmail(string emailAddress, string name, + string userName, string departmentName) + { + try + { + await _emailProvider.SendPasswordChangedByAdministratorMail(name, userName, emailAddress, departmentName); return true; } catch (Exception ex) diff --git a/Core/Resgrid.Services/ExternalIdentityLinkService.cs b/Core/Resgrid.Services/ExternalIdentityLinkService.cs new file mode 100644 index 000000000..3bff12fe9 --- /dev/null +++ b/Core/Resgrid.Services/ExternalIdentityLinkService.cs @@ -0,0 +1,110 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class ExternalIdentityLinkService : IExternalIdentityLinkService + { + private readonly IUserExternalIdentityLinksRepository _linksRepository; + private readonly IDepartmentSsoConfigRepository _ssoConfigRepository; + private readonly IDepartmentMembersRepository _departmentMembersRepository; + + public ExternalIdentityLinkService(IUserExternalIdentityLinksRepository linksRepository, + IDepartmentSsoConfigRepository ssoConfigRepository, + IDepartmentMembersRepository departmentMembersRepository) + { + _linksRepository = linksRepository; + _ssoConfigRepository = ssoConfigRepository; + _departmentMembersRepository = departmentMembersRepository; + } + + public Task GetBySubjectAsync(string departmentSsoConfigId, string externalSubject, + CancellationToken cancellationToken = default) => + _linksRepository.GetActiveBySubjectAsync(departmentSsoConfigId, externalSubject); + + public async Task SaveAsync(UserExternalIdentityLink link, CancellationToken cancellationToken = default) + { + if (link == null) + throw new ArgumentNullException(nameof(link)); + if (string.IsNullOrWhiteSpace(link.UserId) || string.IsNullOrWhiteSpace(link.DepartmentSsoConfigId) || + string.IsNullOrWhiteSpace(link.ExternalSubject) || string.IsNullOrWhiteSpace(link.Issuer)) + throw new ArgumentException("An external identity link must be scoped to a user, configuration, issuer, and subject.", nameof(link)); + + link.UserExternalIdentityLinkId ??= Guid.NewGuid().ToString("N"); + link.LinkedOn = link.LinkedOn == default ? DateTime.UtcNow : link.LinkedOn; + link.IsActive = true; + return await _linksRepository.SaveOrUpdateAsync(link, cancellationToken, true); + } + + public async Task GetSsoManagementStateAsync(string userId, CancellationToken cancellationToken = default) + { + var links = await _linksRepository.GetActiveByUserAsync(userId); + var legacyMembers = (await _departmentMembersRepository.GetAllDepartmentMemberByUserIdAsync(userId))? + .Where(member => !member.IsDeleted && + (!string.IsNullOrWhiteSpace(member.ExternalSsoId) || member.SsoLinkedOn.HasValue)) + .ToList() ?? new System.Collections.Generic.List(); + return new SsoManagementState + { + IsSsoManaged = links.Count > 0 || legacyMembers.Count > 0, + IsScimManaged = links.Any(link => link.LinkMethod == (int)ExternalIdentityLinkMethod.Scim), + // Legacy links did not record the linking attribute. Treat their email as + // externally managed until an administrator performs an explicit unlink. + IsEmailExternallyManaged = legacyMembers.Count > 0 || links.Any(link => link.IsEmailExternallyManaged), + ProviderNames = links.Select(link => $"{link.ProviderType}:{link.Issuer}") + .Concat(legacyMembers.Select(_ => "Legacy SSO link")).Distinct().ToList() + }; + } + + public async Task IsLocalLoginAllowedAsync(string userId, int departmentId, + CancellationToken cancellationToken = default) + { + var links = (await _linksRepository.GetActiveByUserAsync(userId)) + .Where(link => link.DepartmentId == departmentId) + .ToList(); + var member = await _departmentMembersRepository.GetDepartmentMemberByDepartmentIdAndUserIdAsync(departmentId, userId); + var hasLegacyLink = member != null && + (!string.IsNullOrWhiteSpace(member.ExternalSsoId) || member.SsoLinkedOn.HasValue); + + if (links.Count == 0 && !hasLegacyLink) + return true; + + var configs = (await _ssoConfigRepository.GetAllByDepartmentIdAsync(departmentId))?.ToList() + ?? new System.Collections.Generic.List(); + var linkedConfigIds = links.Select(link => link.DepartmentSsoConfigId).ToHashSet(StringComparer.Ordinal); + var applicable = configs.Where(config => config.IsEnabled && + (hasLegacyLink || linkedConfigIds.Contains(config.DepartmentSsoConfigId))).ToList(); + + // A durable SSO link with a missing/disabled config is not a reason to silently + // re-enable a local password. Administrative repair is required. + return applicable.Count > 0 && applicable.All(config => config.AllowLocalLogin); + } + + public async Task IsLocalLoginAllowedAsync(string userId, CancellationToken cancellationToken = default) + { + var links = (await _linksRepository.GetActiveByUserAsync(userId)).ToList(); + if (links.Count == 0) + return true; + + foreach (var departmentLinks in links.GroupBy(link => link.DepartmentId)) + { + var configs = (await _ssoConfigRepository.GetAllByDepartmentIdAsync(departmentLinks.Key))?.ToList() + ?? new System.Collections.Generic.List(); + var linkedConfigIds = departmentLinks.Select(link => link.DepartmentSsoConfigId) + .ToHashSet(StringComparer.Ordinal); + var applicable = configs.Where(config => config.IsEnabled && + linkedConfigIds.Contains(config.DepartmentSsoConfigId)).ToList(); + + if (applicable.Count != linkedConfigIds.Count || applicable.Any(config => !config.AllowLocalLogin)) + return false; + } + + return true; + } + } +} diff --git a/Core/Resgrid.Services/LocalIpLocationProvider.cs b/Core/Resgrid.Services/LocalIpLocationProvider.cs new file mode 100644 index 000000000..eeec83de6 --- /dev/null +++ b/Core/Resgrid.Services/LocalIpLocationProvider.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Config; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// Optional local, operator-managed CIDR-to-location database. The JSON file is an array of + /// { network, country, region, city } objects. No request IP is sent to a third party. + /// + public class LocalIpLocationProvider : IIpLocationProvider + { + private readonly object _reloadLock = new object(); + private readonly ConcurrentDictionary _cache = new(); + private IReadOnlyList _rules = Array.Empty(); + private string _loadedPath; + private DateTime _loadedWriteTimeUtc; + + public Task GetApproximateLocationAsync(string ipAddress, + CancellationToken cancellationToken = default) + { + if (!IPAddress.TryParse(ipAddress, out var address) || + string.IsNullOrWhiteSpace(SessionSecurityConfig.IpLocationDatabasePath)) + return Task.FromResult(null); + + EnsureLoaded(); + if (_cache.TryGetValue(address.ToString(), out var cached)) + return Task.FromResult(cached.IsKnown ? cached : null); + + var result = _rules.FirstOrDefault(rule => rule.Contains(address))?.Location ?? + new IpLocationResult(); + _cache[address.ToString()] = result; + return Task.FromResult(result.IsKnown ? result : null); + } + + private void EnsureLoaded() + { + var path = Path.GetFullPath(SessionSecurityConfig.IpLocationDatabasePath); + var writeTime = File.Exists(path) ? File.GetLastWriteTimeUtc(path) : DateTime.MinValue; + if (string.Equals(path, _loadedPath, StringComparison.OrdinalIgnoreCase) && + writeTime == _loadedWriteTimeUtc) + return; + + lock (_reloadLock) + { + writeTime = File.Exists(path) ? File.GetLastWriteTimeUtc(path) : DateTime.MinValue; + if (string.Equals(path, _loadedPath, StringComparison.OrdinalIgnoreCase) && + writeTime == _loadedWriteTimeUtc) + return; + + var rules = new List(); + if (writeTime != DateTime.MinValue) + { + try + { + var records = JsonConvert.DeserializeObject>(File.ReadAllText(path)) ?? + new List(); + foreach (var record in records) + if (LocationRule.TryCreate(record, out var rule)) rules.Add(rule); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "Unable to load the optional local session IP location database."); + } + } + + _rules = rules.OrderByDescending(rule => rule.PrefixLength).ToList(); + _loadedPath = path; + _loadedWriteTimeUtc = writeTime; + _cache.Clear(); + } + } + + private sealed class LocationRecord + { + public string Network { get; set; } + public string Country { get; set; } + public string Region { get; set; } + public string City { get; set; } + } + + private sealed class LocationRule + { + private byte[] NetworkBytes { get; set; } + public int PrefixLength { get; private set; } + public IpLocationResult Location { get; private set; } + + public static bool TryCreate(LocationRecord record, out LocationRule rule) + { + rule = null; + var parts = record?.Network?.Split('/'); + if (parts?.Length != 2 || !IPAddress.TryParse(parts[0], out var network) || + !int.TryParse(parts[1], out var prefix)) return false; + var bytes = network.GetAddressBytes(); + if (prefix < 0 || prefix > bytes.Length * 8) return false; + rule = new LocationRule + { + NetworkBytes = bytes, + PrefixLength = prefix, + Location = new IpLocationResult + { + Country = record.Country, + Region = record.Region, + City = record.City + } + }; + return true; + } + + public bool Contains(IPAddress address) + { + var candidate = address.GetAddressBytes(); + if (candidate.Length != NetworkBytes.Length) return false; + var fullBytes = PrefixLength / 8; + for (var i = 0; i < fullBytes; i++) + if (candidate[i] != NetworkBytes[i]) return false; + var remainingBits = PrefixLength % 8; + if (remainingBits == 0) return true; + var mask = (byte)(0xff << (8 - remainingBits)); + return (candidate[fullBytes] & mask) == (NetworkBytes[fullBytes] & mask); + } + } + } +} diff --git a/Core/Resgrid.Services/PasswordRecoveryService.cs b/Core/Resgrid.Services/PasswordRecoveryService.cs new file mode 100644 index 000000000..791655a73 --- /dev/null +++ b/Core/Resgrid.Services/PasswordRecoveryService.cs @@ -0,0 +1,107 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Config; +using Resgrid.Model.Providers; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class PasswordRecoveryService : IPasswordRecoveryService + { + private const string CachePrefix = "security:password-recovery:"; + private readonly ICacheProvider _cacheProvider; + + public PasswordRecoveryService(ICacheProvider cacheProvider) + { + _cacheProvider = cacheProvider; + } + + public async Task IssueAsync(string userId, string email, string ipAddress, + long authenticationGeneration, string securityStamp, + CancellationToken cancellationToken = default) + { + var rateWindow = TimeSpan.FromHours(1); + var normalizedEmail = (email ?? string.Empty).Trim().ToUpperInvariant(); + var ipCount = await _cacheProvider.IncrementAsync( + $"{CachePrefix}rate:ip:{Hash(ipAddress ?? "unknown")}", rateWindow); + var accountCount = await _cacheProvider.IncrementAsync( + $"{CachePrefix}rate:account:{Hash(normalizedEmail)}", rateWindow); + + if (ipCount == 0 || accountCount == 0 || + ipCount > SessionSecurityConfig.PublicResetIpLimitPerHour || + accountCount > SessionSecurityConfig.PublicResetAccountLimitPerHour) + { + return new PasswordRecoveryIssueResult { RateLimited = true }; + } + + // Rate-limit unknown accounts too, but only persist a reset request for an eligible user. + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(email)) + return new PasswordRecoveryIssueResult(); + + var now = DateTime.UtcNow; + var lifetime = TimeSpan.FromMinutes(Math.Max(5, SessionSecurityConfig.PublicResetLinkLifetimeMinutes)); + var token = ToBase64Url(RandomNumberGenerator.GetBytes(32)); + var request = new PasswordRecoveryRequest + { + UserId = userId, + Email = email, + AuthenticationGeneration = authenticationGeneration, + SecurityStampHash = Hash(securityStamp), + CreatedOn = now, + ExpiresOn = now.Add(lifetime) + }; + + var saved = await _cacheProvider.SetStringAsync(GetRequestKey(token), + JsonConvert.SerializeObject(request), lifetime); + return new PasswordRecoveryIssueResult { Issued = saved, Token = saved ? token : null }; + } + + public async Task GetAsync(string token, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(token)) + return null; + + var json = await _cacheProvider.GetStringAsync(GetRequestKey(token)); + if (string.IsNullOrWhiteSpace(json)) + return null; + + PasswordRecoveryRequest request; + try + { + request = JsonConvert.DeserializeObject(json); + } + catch (JsonException) + { + return null; + } + + return request?.ExpiresOn > DateTime.UtcNow ? request : null; + } + + public async Task TryConsumeAsync(string token, CancellationToken cancellationToken = default) + { + if (await GetAsync(token, cancellationToken) == null) + return false; + + var lifetime = TimeSpan.FromMinutes(Math.Max(5, SessionSecurityConfig.PublicResetLinkLifetimeMinutes)); + return await _cacheProvider.IncrementAsync(GetUsedKey(token), lifetime) == 1; + } + + public Task RemoveAsync(string token, CancellationToken cancellationToken = default) => + _cacheProvider.RemoveAsync(GetRequestKey(token)); + + private static string GetRequestKey(string token) => $"{CachePrefix}request:{Hash(token)}"; + private static string GetUsedKey(string token) => $"{CachePrefix}used:{Hash(token)}"; + + private static string Hash(string value) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty))).ToLowerInvariant(); + + private static string ToBase64Url(byte[] value) => + Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } +} diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 3c36cdc4b..e9df11c56 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -156,6 +156,11 @@ protected override void Load(ContainerBuilder builder) // SSO / Security Policy builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); //builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); diff --git a/Core/Resgrid.Services/UserSessionService.cs b/Core/Resgrid.Services/UserSessionService.cs new file mode 100644 index 000000000..4240147cf --- /dev/null +++ b/Core/Resgrid.Services/UserSessionService.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class UserSessionService : IUserSessionService + { + private readonly IUserSessionsRepository _sessionsRepository; + private readonly IIdentityUserRepository _identityUserRepository; + private readonly IIdentityRepository _identityRepository; + private readonly IDepartmentsService _departmentsService; + private readonly IDepartmentSsoService _departmentSsoService; + private readonly IClientSessionMetadataParser _metadataParser; + private readonly IIpLocationProvider _ipLocationProvider; + + public UserSessionService(IUserSessionsRepository sessionsRepository, + IIdentityUserRepository identityUserRepository, IIdentityRepository identityRepository, + IDepartmentsService departmentsService, IDepartmentSsoService departmentSsoService, + IClientSessionMetadataParser metadataParser, + IIpLocationProvider ipLocationProvider) + { + _sessionsRepository = sessionsRepository; + _identityUserRepository = identityUserRepository; + _identityRepository = identityRepository; + _departmentsService = departmentsService; + _departmentSsoService = departmentSsoService; + _metadataParser = metadataParser; + _ipLocationProvider = ipLocationProvider; + } + + public async Task CreateSessionAsync(SessionIssueContext context, CancellationToken cancellationToken = default) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + if (string.IsNullOrWhiteSpace(context.UserId)) + throw new ArgumentException("A user is required to create a session.", nameof(context)); + + var now = DateTime.UtcNow; + if (context.DepartmentId.HasValue) + { + var member = await _departmentsService.GetDepartmentMemberAsync(context.UserId, + context.DepartmentId.Value, bypassCache: true); + if (member == null || member.IsDeleted || member.IsDisabled == true) + throw new SessionCreationDeniedException("membership_inactive"); + + if (TryGetDepartmentPolicyGate(out var policyGate) && now >= policyGate) + { + var policy = await _departmentSsoService.GetSecurityPolicyForDepartmentAsync( + context.DepartmentId.Value, cancellationToken); + if (policy?.MaxConcurrentSessions > 0) + { + var activeSessions = await _sessionsRepository.GetActiveByUserAsync(context.UserId, now); + var managedCount = activeSessions.Count(session => + session.DepartmentId == context.DepartmentId && session.CreatedOn >= policyGate); + if (managedCount >= policy.MaxConcurrentSessions) + throw new SessionCreationDeniedException("maximum_sessions"); + } + } + } + + var metadata = _metadataParser.Parse(context.UserAgent, context.DeviceName, context.DeviceType, + context.OperatingSystem, context.Browser, context.ApplicationVersion); + var location = await ResolveLocationAsync(context.IpAddress, context.Country, context.Region, + context.City, cancellationToken); + var session = new UserSession + { + UserSessionId = Guid.NewGuid().ToString("N"), + UserId = context.UserId, + DepartmentId = context.DepartmentId, + AuthenticationGeneration = context.AuthenticationGeneration, + State = (int)UserSessionState.Active, + StateVersion = 0, + ClientApplication = (int)context.ClientApplication, + ClientInstanceIdHash = Limit(context.ClientInstanceIdHash, 128), + DeviceName = Limit(metadata.DeviceName, 256), + DeviceType = Limit(metadata.DeviceType, 128), + OperatingSystem = Limit(metadata.OperatingSystem, 128), + Browser = Limit(metadata.Browser, 128), + ApplicationVersion = Limit(metadata.ApplicationVersion, 64), + AuthenticationMethod = (int)context.AuthenticationMethod, + DepartmentSsoConfigId = Limit(context.DepartmentSsoConfigId, 128), + OpenIddictAuthorizationId = Limit(context.OpenIddictAuthorizationId, 128), + WebCookieTicketKey = Limit(context.WebCookieTicketKey, 512), + CreatedOn = now, + LastActiveOn = now, + ExpiresOn = context.ExpiresOn > now ? context.ExpiresOn : now.AddHours(24), + FirstIpAddress = CanonicalIp(context.IpAddress), + LastIpAddress = CanonicalIp(context.IpAddress), + LastCountry = Limit(location?.Country, 128), + LastRegion = Limit(location?.Region, 128), + LastCity = Limit(location?.City, 128), + UserAgent = Limit(context.UserAgent, Math.Max(128, SessionSecurityConfig.UserAgentMaximumLength)), + IsLegacyAdopted = context.IsLegacyAdopted + }; + + return await _sessionsRepository.InsertAsync(session, cancellationToken, true); + } + + public async Task ValidateAsync(SessionPrincipalContext context, CancellationToken cancellationToken = default) + { + if (context == null || string.IsNullOrWhiteSpace(context.UserId)) + return SessionValidationResult.Invalid("missing_user"); + + var user = await _identityUserRepository.GetByIdAsync(context.UserId); + if (user == null) + return SessionValidationResult.Invalid("user_not_found"); + + if (user.CredentialsValidAfterUtc.HasValue && + (!context.CredentialIssuedOn.HasValue || context.CredentialIssuedOn.Value <= user.CredentialsValidAfterUtc.Value)) + return SessionValidationResult.Invalid("credential_cutoff"); + + if (string.IsNullOrWhiteSpace(context.SessionId)) + { + if (!SessionSecurityConfig.LegacyAdoptionEnabled) + return SessionValidationResult.Invalid("session_required"); + + if (DateTime.TryParse(SessionSecurityConfig.RequireSessionClaimForCredentialsIssuedAfterUtc, out var requiredAfter) && + context.CredentialIssuedOn.HasValue && context.CredentialIssuedOn.Value >= requiredAfter.ToUniversalTime()) + return SessionValidationResult.Invalid("session_required"); + + return SessionValidationResult.Valid(canAdoptLegacy: true); + } + + var session = await _sessionsRepository.GetByIdAsync(context.SessionId); + if (session == null) + return SessionValidationResult.Invalid("session_not_found"); + if (!string.Equals(session.UserId, context.UserId, StringComparison.Ordinal)) + return SessionValidationResult.Invalid("session_user_mismatch"); + if (session.State != (int)UserSessionState.Active) + return SessionValidationResult.Invalid("session_revoked"); + if (session.ExpiresOn <= DateTime.UtcNow) + return SessionValidationResult.Invalid("session_expired"); + if (session.AuthenticationGeneration != user.AuthenticationGeneration || + (context.AuthenticationGeneration.HasValue && context.AuthenticationGeneration.Value != user.AuthenticationGeneration)) + return SessionValidationResult.Invalid("authentication_generation_mismatch"); + if (session.DepartmentId.HasValue && context.DepartmentId.HasValue && session.DepartmentId != context.DepartmentId) + return SessionValidationResult.Invalid("session_department_mismatch"); + if (session.DepartmentId.HasValue) + { + var member = await _departmentsService.GetDepartmentMemberAsync(context.UserId, + session.DepartmentId.Value, bypassCache: true); + if (member == null || member.IsDeleted || member.IsDisabled == true) + return SessionValidationResult.Invalid("membership_inactive"); + + if (TryGetDepartmentPolicyGate(out var policyGate) && session.CreatedOn >= policyGate) + { + var policy = await _departmentSsoService.GetSecurityPolicyForDepartmentAsync( + session.DepartmentId.Value, cancellationToken); + if (policy?.SessionTimeoutMinutes > 0 && + session.LastActiveOn <= DateTime.UtcNow.AddMinutes(-policy.SessionTimeoutMinutes)) + return SessionValidationResult.Invalid("session_idle_timeout"); + } + } + + return SessionValidationResult.Valid(session); + } + + public async Task AdoptLegacyAsync(LegacySessionContext context, CancellationToken cancellationToken = default) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + if (!string.IsNullOrWhiteSpace(context.StableCredentialIdentifier)) + { + var existing = await _sessionsRepository.GetByAuthorizationIdAsync(context.StableCredentialIdentifier); + if (existing != null) + return existing; + context.OpenIddictAuthorizationId = context.StableCredentialIdentifier; + } + + context.IsLegacyAdopted = true; + if (context.ClientApplication == default) + context.ClientApplication = UserSessionClientApplication.UnknownLegacy; + context.AuthenticationMethod = UserSessionAuthenticationMethod.LegacyUnknown; + return await CreateSessionAsync(context, cancellationToken); + } + + public async Task TouchAsync(string sessionId, RequestActivity activity, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(sessionId) || activity == null) + return; + + var occurredOn = activity.OccurredOn == default ? DateTime.UtcNow : activity.OccurredOn; + var writeBefore = occurredOn.AddMinutes(-Math.Max(1, SessionSecurityConfig.LastActivityWriteIntervalMinutes)); + var location = await ResolveLocationAsync(activity.IpAddress, activity.Country, activity.Region, + activity.City, cancellationToken); + await _sessionsRepository.TouchAsync(sessionId, occurredOn, writeBefore, + CanonicalIp(activity.IpAddress), Limit(location?.Country, 128), Limit(location?.Region, 128), + Limit(location?.City, 128), Limit(activity.UserAgent, Math.Max(128, SessionSecurityConfig.UserAgentMaximumLength)), + cancellationToken); + } + + public async Task MoveSessionToDepartmentAsync(string userId, string sessionId, int departmentId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(sessionId) || departmentId <= 0) + return false; + + var member = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, bypassCache: true); + if (member == null || member.IsDeleted || member.IsDisabled == true) + return false; + + return await _sessionsRepository.UpdateDepartmentAsync(userId, sessionId, departmentId, + cancellationToken) == 1; + } + + public async Task> GetActiveForUserAsync(string userId, CancellationToken cancellationToken = default) + { + var sessions = await _sessionsRepository.GetActiveByUserAsync(userId, DateTime.UtcNow); + return sessions.Select(session => new UserSessionSummary + { + UserSessionId = session.UserSessionId, + DepartmentId = session.DepartmentId, + State = (UserSessionState)session.State, + ClientApplication = (UserSessionClientApplication)session.ClientApplication, + DeviceName = session.DeviceName, + DeviceType = session.DeviceType, + OperatingSystem = session.OperatingSystem, + Browser = session.Browser, + ApplicationVersion = session.ApplicationVersion, + AuthenticationMethod = (UserSessionAuthenticationMethod)session.AuthenticationMethod, + CreatedOn = session.CreatedOn, + LastActiveOn = session.LastActiveOn, + ExpiresOn = session.ExpiresOn, + LastIpAddress = session.LastIpAddress, + LastCountry = session.LastCountry, + LastRegion = session.LastRegion, + LastCity = session.LastCity, + UserAgent = session.UserAgent, + IsLegacyAdopted = session.IsLegacyAdopted + }).ToList(); + } + + public async Task RevokeSessionAsync(string actorUserId, string targetUserId, string sessionId, + UserSessionRevocationReason reason, CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var count = await _sessionsRepository.RevokeAsync(targetUserId, sessionId, actorUserId, (int)reason, now, cancellationToken); + return new RevocationResult { RevokedSessionCount = count, RevokedOn = now }; + } + + public async Task RevokeOtherSessionsAsync(string userId, string currentSessionId, + UserSessionRevocationReason reason, CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var count = await _sessionsRepository.RevokeOthersAsync(userId, currentSessionId, (int)reason, now, cancellationToken); + return new RevocationResult { RevokedSessionCount = count, RevokedOn = now }; + } + + public async Task RevokeAllAsync(string actorUserId, string targetUserId, + UserSessionRevocationReason reason, DateTime validAfterUtc, CancellationToken cancellationToken = default) + { + var user = await _identityUserRepository.GetByIdAsync(targetUserId); + if (user == null) + return new RevocationResult { RevokedOn = validAfterUtc }; + + user.AuthenticationGeneration++; + user.CredentialsValidAfterUtc = validAfterUtc; + user.AuthenticationStateChangedOn = validAfterUtc; + user.SecurityStamp = Guid.NewGuid().ToString(); + await _identityUserRepository.UpdateAsync(user, cancellationToken); + + return await RevokePersistedCredentialsAsync(actorUserId, targetUserId, reason, validAfterUtc, cancellationToken); + } + + public Task RevokeAllAfterCredentialChangeAsync(string actorUserId, string targetUserId, + UserSessionRevocationReason reason, DateTime validAfterUtc, CancellationToken cancellationToken = default) + { + return RevokePersistedCredentialsAsync(actorUserId, targetUserId, reason, validAfterUtc, cancellationToken); + } + + private async Task RevokePersistedCredentialsAsync(string actorUserId, string targetUserId, + UserSessionRevocationReason reason, DateTime validAfterUtc, CancellationToken cancellationToken) + { + var count = await _sessionsRepository.RevokeAllAsync(targetUserId, actorUserId, (int)reason, validAfterUtc, cancellationToken); + await _identityRepository.CleanUpOIDCTokensByUserAsync(targetUserId); + return new RevocationResult { RevokedSessionCount = count, RevokedOn = validAfterUtc }; + } + + public async Task RevokeDepartmentSessionsAsync(string targetUserId, int departmentId, + UserSessionRevocationReason reason, CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var count = await _sessionsRepository.RevokeDepartmentAsync(targetUserId, departmentId, (int)reason, now, cancellationToken); + return new RevocationResult { RevokedSessionCount = count, RevokedOn = now }; + } + + private static string Limit(string value, int maximumLength) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + var sanitized = value.Replace("\r", " ").Replace("\n", " ").Trim(); + return sanitized.Length <= maximumLength ? sanitized : sanitized.Substring(0, maximumLength); + } + + private async Task ResolveLocationAsync(string ipAddress, string country, + string region, string city, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(country) || !string.IsNullOrWhiteSpace(region) || + !string.IsNullOrWhiteSpace(city)) + return new IpLocationResult {Country = country, Region = region, City = city}; + try + { + return await _ipLocationProvider.GetApproximateLocationAsync(CanonicalIp(ipAddress), cancellationToken); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "Optional session IP location lookup failed."); + return null; + } + } + + private static string CanonicalIp(string value) => + IPAddress.TryParse(value, out var address) ? address.ToString() : null; + + private static bool TryGetDepartmentPolicyGate(out DateTime gateUtc) + { + if (DateTimeOffset.TryParse(SessionSecurityConfig.DepartmentSessionPolicyEnforcementAfterUtc, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal | + System.Globalization.DateTimeStyles.AdjustToUniversal, out var parsed)) + { + gateUtc = parsed.UtcDateTime; + return true; + } + + gateUtc = default; + return false; + } + } +} diff --git a/Core/Resgrid.Services/UsersService.cs b/Core/Resgrid.Services/UsersService.cs index 6c32224f8..a260621f8 100644 --- a/Core/Resgrid.Services/UsersService.cs +++ b/Core/Resgrid.Services/UsersService.cs @@ -211,28 +211,6 @@ public void InitUserExtInfo(string userId) _identityRepository.InitUserExtInfo(userId); } - public async Task UpdateUsername(string oldUsername, string newUsername) - { - if (String.IsNullOrEmpty(oldUsername) || String.IsNullOrEmpty(newUsername)) - return null; - - _identityRepository.UpdateUsername(oldUsername, newUsername); - var user = await _identityRepository.GetUserByUserNameAsync(newUsername); - - return user; - } - - public IdentityUser UpdateEmail(string userId, string newEmail) - { - if (String.IsNullOrEmpty(userId) || String.IsNullOrEmpty(newEmail)) - return null; - - _identityRepository.UpdateEmail(userId, newEmail); - var user = _identityRepository.GetUserById(userId); - - return user; - } - public IdentityUser SaveUser(IdentityUser user) { _identityRepository.Update(user); diff --git a/Providers/Resgrid.Providers.Bus/SignalrProvider.cs b/Providers/Resgrid.Providers.Bus/SignalrProvider.cs index a49fac868..9e31df5d5 100644 --- a/Providers/Resgrid.Providers.Bus/SignalrProvider.cs +++ b/Providers/Resgrid.Providers.Bus/SignalrProvider.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; using System.Net.Http; +using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; using Resgrid.Framework; @@ -11,6 +14,9 @@ namespace Resgrid.Providers.Bus public class SignalrProvider : ISignalrProvider { private static HubConnection _hubConnection; + private static readonly SemaphoreSlim TokenLock = new SemaphoreSlim(1, 1); + private static string _accessToken; + private static DateTime _accessTokenRefreshOn; //private static IHubProxy _eventingHubProxy; public SignalrProvider() @@ -90,14 +96,13 @@ private void Create() { _hubConnection = new HubConnectionBuilder() .WithUrl($"{Config.SystemBehaviorConfig.ResgridEventingBaseUrl}/eventingHub", options => { - //options.UseDefaultCredentials = true; + options.AccessTokenProvider = GetAccessTokenAsync; options.HttpMessageHandlerFactory = (msg) => { - if (msg is HttpClientHandler clientHandler) + if (Config.ApiConfig.BypassSslChecks && msg is HttpClientHandler clientHandler) { - // bypass SSL certificate validation - clientHandler.ServerCertificateCustomValidationCallback += - (sender, certificate, chain, sslPolicyErrors) => { return true; }; + clientHandler.ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; } return msg; @@ -113,6 +118,53 @@ private void Create() //}; } + private static async Task GetAccessTokenAsync() + { + if (!string.IsNullOrWhiteSpace(_accessToken) && _accessTokenRefreshOn > DateTime.UtcNow) + return _accessToken; + if (string.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) + return null; + + await TokenLock.WaitAsync(); + try + { + if (!string.IsNullOrWhiteSpace(_accessToken) && _accessTokenRefreshOn > DateTime.UtcNow) + return _accessToken; + + using var handler = new HttpClientHandler(); + if (Config.ApiConfig.BypassSslChecks) + handler.ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; + using var client = new HttpClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Post, + $"{Config.SystemBehaviorConfig.ResgridApiBaseUrl.TrimEnd('/')}/api/v4/connect/token") + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = "resgrid_eventing", + ["client_secret"] = Config.ApiConfig.BackendInternalApikey + }) + }; + using var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) + return null; + + await using var stream = await response.Content.ReadAsStreamAsync(); + using var json = await JsonDocument.ParseAsync(stream); + if (!json.RootElement.TryGetProperty("access_token", out var tokenElement)) + return null; + + _accessToken = tokenElement.GetString(); + _accessTokenRefreshOn = DateTime.UtcNow.AddMinutes(4); + return _accessToken; + } + finally + { + TokenLock.Release(); + } + } + private async Task Connect() { try diff --git a/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs b/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs index 512da8ac1..d2909a8b9 100644 --- a/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs +++ b/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs @@ -8,6 +8,7 @@ using Resgrid.Model.Identity; using Resgrid.Model.Repositories; using Resgrid.Model.Services; +using Resgrid.Model.Security; using IdentityRole = Resgrid.Model.Identity.IdentityRole; using IdentityUser = Resgrid.Model.Identity.IdentityUser; @@ -62,6 +63,8 @@ public override async Task CreateAsync(TUser user) id.AddClaim(new Claim(Options.ClaimsIdentity.UserIdClaimType, userId)); id.AddClaim(new Claim(Options.ClaimsIdentity.UserNameClaimType, userName)); + id.AddClaim(new Claim(SessionClaimTypes.AuthenticationGeneration, + user.AuthenticationGeneration.ToString(System.Globalization.CultureInfo.InvariantCulture))); if (UserManager.SupportsUserSecurityStamp) { diff --git a/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs b/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs index 17a77e17b..f44964f19 100644 --- a/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs +++ b/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs @@ -307,31 +307,33 @@ public async Task SendMessageMail(string email, string subject, string mes return false; } - public async Task SendPasswordResetMail(string name, string password, string userName, string email, string departmentName) + public async Task SendPasswordRecoveryMail(string name, string email, + string departmentName, string resetUrl, string ipAddress, string userAgent, string requestedOn, + bool isSsoManaged) { var templateModel = new Dictionary { - { "name", name }, - { "department_Name", departmentName }, - { "login_url", LOGIN_URL }, - { "username", userName }, - { "password", password }, + { "name", System.Net.WebUtility.HtmlEncode(name) }, + { "department_name", System.Net.WebUtility.HtmlEncode(departmentName) }, { "support_url", LIVECHAT_URL }, - { "action_url", LOGIN_URL }, - { "operating_system", "" }, - { "browser_name", "" }, + { "reset_url", System.Net.WebUtility.HtmlEncode(resetUrl) }, + { "ip_address", System.Net.WebUtility.HtmlEncode(ipAddress) }, + { "user_agent", System.Net.WebUtility.HtmlEncode(userAgent) }, + { "requested_on", System.Net.WebUtility.HtmlEncode(requestedOn) }, + { "has_reset_link", !isSsoManaged }, + { "is_sso_managed", isSsoManaged }, }; try { - var template = Mustachio.Parser.Parse(GetTempate("PasswordReset.html")); + var template = Mustachio.Parser.Parse(GetTempate("PasswordRecovery.html")); var content = template(templateModel); Email newEmail = new Email(); newEmail.HtmlBody = content; newEmail.Sender = FROM_EMAIL; newEmail.From = FROM_EMAIL; - newEmail.Subject = $"Resgrid Password Reset"; + newEmail.Subject = "Resgrid password reset request"; newEmail.To.Add(email); return await _emailSender.Send(newEmail); @@ -343,6 +345,38 @@ public async Task SendPasswordResetMail(string name, string password, stri return false; } + public async Task SendPasswordChangedByAdministratorMail(string name, string userName, + string email, string departmentName) + { + var templateModel = new Dictionary + { + { "name", System.Net.WebUtility.HtmlEncode(name) }, + { "department_name", System.Net.WebUtility.HtmlEncode(departmentName) }, + { "username", System.Net.WebUtility.HtmlEncode(userName) }, + { "login_url", LOGIN_URL }, + { "support_url", LIVECHAT_URL } + }; + + try + { + var template = Mustachio.Parser.Parse(GetTempate("PasswordChangedByAdministrator.html")); + var content = template(templateModel); + var newEmail = new Email + { + HtmlBody = content, + Sender = FROM_EMAIL, + From = FROM_EMAIL, + Subject = "Your Resgrid password was changed" + }; + newEmail.To.Add(email); + return await _emailSender.Send(newEmail); + } + catch (Exception) + { + return false; + } + } + public async Task SendPaymentReciept(string departmentName, string name, string processDate, string amount, string email, string processor, string transactionId, string planName, string effectiveDates, string nextBillingDate, int paymentId) { @@ -410,7 +444,7 @@ public async Task SendUpgradePaymentReciept(string departmentName, string throw new NotImplementedException(); } - public async Task SendWelcomeMail(string name, string departmentName, string userName, string password, string email, int departmentId) + public async Task SendWelcomeMail(string name, string departmentName, string userName, string email, int departmentId) { var templateModel = new Dictionary { diff --git a/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj b/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj index ffb80352f..85d3305d8 100644 --- a/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj +++ b/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj @@ -11,7 +11,8 @@ - + + @@ -28,7 +29,8 @@ - + + @@ -45,4 +47,4 @@ - \ No newline at end of file + diff --git a/Providers/Resgrid.Providers.Email/Template/PasswordChangedByAdministrator.html b/Providers/Resgrid.Providers.Email/Template/PasswordChangedByAdministrator.html new file mode 100644 index 000000000..632e96a87 --- /dev/null +++ b/Providers/Resgrid.Providers.Email/Template/PasswordChangedByAdministrator.html @@ -0,0 +1,35 @@ + + + + + + Your Resgrid password was changed + + + +
+
Resgrid
+
+
+

Your password was changed

+

Hi {{name}},

+

An administrator in {{department_name}} changed the password for your Resgrid account {{username}}.

+

All existing Resgrid web, mobile, dispatch, and big-board sessions were signed out, and their access and refresh tokens were revoked. Use the new password supplied to you by your administrator.

+

Log in to Resgrid

+

If you did not expect this change, contact your department administrator or Resgrid support.

+
+
+ +
+ + diff --git a/Providers/Resgrid.Providers.Email/Template/PasswordRecovery.html b/Providers/Resgrid.Providers.Email/Template/PasswordRecovery.html new file mode 100644 index 000000000..3e5e965a7 --- /dev/null +++ b/Providers/Resgrid.Providers.Email/Template/PasswordRecovery.html @@ -0,0 +1,48 @@ + + + + + + Resgrid password reset request + + + +
+
Resgrid
+
+
+

Password reset requested

+

Hi {{name}},

+

You or an administrator requested a password reset for your Resgrid account in {{department_name}}.

+ {{#has_reset_link}} +

This link is short-lived and can only be used once.

+

Choose a new password

+

Changing your password will immediately sign you out of every Resgrid web, mobile, dispatch, and big-board session and revoke all existing access and refresh tokens.

+ {{/has_reset_link}} + {{#is_sso_managed}} +

This account is linked via SSO and cannot change its password via the Resgrid system. Contact your administrator.

+ {{/is_sso_managed}} +
+ Request details
+ Time (UTC): {{requested_on}}
+ IP address: {{ip_address}}
+ Browser/device: {{user_agent}} +
+

If you did not expect this request, no password has been changed. Contact your administrator or Resgrid support if you are concerned.

+
+
+ +
+ + diff --git a/Providers/Resgrid.Providers.Email/Template/PasswordReset.html b/Providers/Resgrid.Providers.Email/Template/PasswordReset.html deleted file mode 100644 index d373b487e..000000000 --- a/Providers/Resgrid.Providers.Email/Template/PasswordReset.html +++ /dev/null @@ -1,491 +0,0 @@ - - - - - - Set up a new password for [Product Name] - - - - - Your new Resgrid password. - - - - - - - diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0120_AddUserAuthenticationState.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0120_AddUserAuthenticationState.cs new file mode 100644 index 000000000..bb6424fc0 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0120_AddUserAuthenticationState.cs @@ -0,0 +1,30 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(120)] + public class M0120_AddUserAuthenticationState : Migration + { + public override void Up() + { + if (!Schema.Table("AspNetUsers").Column("AuthenticationGeneration").Exists()) + Alter.Table("AspNetUsers").AddColumn("AuthenticationGeneration").AsInt64().NotNullable().WithDefaultValue(0L); + + if (!Schema.Table("AspNetUsers").Column("CredentialsValidAfterUtc").Exists()) + Alter.Table("AspNetUsers").AddColumn("CredentialsValidAfterUtc").AsDateTime2().Nullable(); + + if (!Schema.Table("AspNetUsers").Column("AuthenticationStateChangedOn").Exists()) + Alter.Table("AspNetUsers").AddColumn("AuthenticationStateChangedOn").AsDateTime2().Nullable(); + } + + public override void Down() + { + if (Schema.Table("AspNetUsers").Column("AuthenticationStateChangedOn").Exists()) + Delete.Column("AuthenticationStateChangedOn").FromTable("AspNetUsers"); + if (Schema.Table("AspNetUsers").Column("CredentialsValidAfterUtc").Exists()) + Delete.Column("CredentialsValidAfterUtc").FromTable("AspNetUsers"); + if (Schema.Table("AspNetUsers").Column("AuthenticationGeneration").Exists()) + Delete.Column("AuthenticationGeneration").FromTable("AspNetUsers"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0121_AddUserSessions.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0121_AddUserSessions.cs new file mode 100644 index 000000000..18c283897 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0121_AddUserSessions.cs @@ -0,0 +1,69 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(121)] + public class M0121_AddUserSessions : Migration + { + public override void Up() + { + if (Schema.Table("UserSessions").Exists()) + return; + + Create.Table("UserSessions") + .WithColumn("UserSessionId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("DepartmentId").AsInt32().Nullable() + .WithColumn("AuthenticationGeneration").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("StateVersion").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("ClientApplication").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ClientInstanceIdHash").AsString(128).Nullable() + .WithColumn("DeviceName").AsString(256).Nullable() + .WithColumn("DeviceType").AsString(128).Nullable() + .WithColumn("OperatingSystem").AsString(128).Nullable() + .WithColumn("Browser").AsString(128).Nullable() + .WithColumn("ApplicationVersion").AsString(64).Nullable() + .WithColumn("AuthenticationMethod").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("DepartmentSsoConfigId").AsString(128).Nullable() + .WithColumn("OpenIddictAuthorizationId").AsString(128).Nullable() + .WithColumn("WebCookieTicketKey").AsString(512).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("LastActiveOn").AsDateTime2().NotNullable() + .WithColumn("ExpiresOn").AsDateTime2().NotNullable() + .WithColumn("FirstIpAddress").AsString(64).Nullable() + .WithColumn("LastIpAddress").AsString(64).Nullable() + .WithColumn("LastCountry").AsString(128).Nullable() + .WithColumn("LastRegion").AsString(128).Nullable() + .WithColumn("LastCity").AsString(128).Nullable() + .WithColumn("UserAgent").AsString(1024).Nullable() + .WithColumn("IsLegacyAdopted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("RevokedOn").AsDateTime2().Nullable() + .WithColumn("RevokedByUserId").AsString(128).Nullable() + .WithColumn("RevocationReason").AsInt32().Nullable(); + + Create.Index("IX_UserSessions_User_State_Expiry_Activity") + .OnTable("UserSessions") + .OnColumn("UserId").Ascending() + .OnColumn("State").Ascending() + .OnColumn("ExpiresOn").Ascending() + .OnColumn("LastActiveOn").Descending(); + Create.Index("IX_UserSessions_Department_User_State") + .OnTable("UserSessions") + .OnColumn("DepartmentId").Ascending() + .OnColumn("UserId").Ascending() + .OnColumn("State").Ascending(); + Create.Index("IX_UserSessions_Revoked_Expiry") + .OnTable("UserSessions") + .OnColumn("RevokedOn").Ascending() + .OnColumn("ExpiresOn").Ascending(); + Execute.Sql("CREATE UNIQUE INDEX [UX_UserSessions_OpenIddictAuthorizationId] ON [UserSessions] ([OpenIddictAuthorizationId]) WHERE [OpenIddictAuthorizationId] IS NOT NULL;"); + } + + public override void Down() + { + if (Schema.Table("UserSessions").Exists()) + Delete.Table("UserSessions"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0122_AddUserExternalIdentityLinks.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0122_AddUserExternalIdentityLinks.cs new file mode 100644 index 000000000..190461168 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0122_AddUserExternalIdentityLinks.cs @@ -0,0 +1,53 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(122)] + public class M0122_AddUserExternalIdentityLinks : Migration + { + public override void Up() + { + if (Schema.Table("UserExternalIdentityLinks").Exists()) + return; + + Create.Table("UserExternalIdentityLinks") + .WithColumn("UserExternalIdentityLinkId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("DepartmentMemberId").AsInt32().NotNullable() + .WithColumn("DepartmentSsoConfigId").AsString(128).NotNullable() + .WithColumn("ProviderType").AsInt32().NotNullable() + .WithColumn("Issuer").AsString(1024).NotNullable() + .WithColumn("ExternalSubject").AsString(512).NotNullable() + .WithColumn("LinkMethod").AsInt32().NotNullable() + .WithColumn("EmailAtLink").AsString(512).Nullable() + .WithColumn("IsEmailExternallyManaged").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("IsActive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("LinkedOn").AsDateTime2().NotNullable() + .WithColumn("LastLoginOn").AsDateTime2().Nullable() + .WithColumn("UnlinkedOn").AsDateTime2().Nullable() + .WithColumn("UnlinkedByUserId").AsString(128).Nullable(); + + Create.Index("UX_UserExternalIdentityLinks_Config_Subject") + .OnTable("UserExternalIdentityLinks") + .OnColumn("DepartmentSsoConfigId").Ascending() + .OnColumn("ExternalSubject").Ascending() + .WithOptions().Unique(); + Create.Index("UX_UserExternalIdentityLinks_User_Config") + .OnTable("UserExternalIdentityLinks") + .OnColumn("UserId").Ascending() + .OnColumn("DepartmentSsoConfigId").Ascending() + .WithOptions().Unique(); + Create.Index("IX_UserExternalIdentityLinks_Department_Member") + .OnTable("UserExternalIdentityLinks") + .OnColumn("DepartmentId").Ascending() + .OnColumn("DepartmentMemberId").Ascending(); + } + + public override void Down() + { + if (Schema.Table("UserExternalIdentityLinks").Exists()) + Delete.Table("UserExternalIdentityLinks"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0123_AddAuthenticationAuditContext.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0123_AddAuthenticationAuditContext.cs new file mode 100644 index 000000000..65a3d6969 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0123_AddAuthenticationAuditContext.cs @@ -0,0 +1,28 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(123)] + public class M0123_AddAuthenticationAuditContext : Migration + { + public override void Up() + { + if (!Schema.Table("SystemAudits").Column("TargetUserId").Exists()) + Alter.Table("SystemAudits").AddColumn("TargetUserId").AsString(128).Nullable(); + if (!Schema.Table("SystemAudits").Column("SessionId").Exists()) + Alter.Table("SystemAudits").AddColumn("SessionId").AsString(128).Nullable(); + if (!Schema.Table("SystemAudits").Column("CorrelationId").Exists()) + Alter.Table("SystemAudits").AddColumn("CorrelationId").AsString(128).Nullable(); + } + + public override void Down() + { + if (Schema.Table("SystemAudits").Column("CorrelationId").Exists()) + Delete.Column("CorrelationId").FromTable("SystemAudits"); + if (Schema.Table("SystemAudits").Column("SessionId").Exists()) + Delete.Column("SessionId").FromTable("SystemAudits"); + if (Schema.Table("SystemAudits").Column("TargetUserId").Exists()) + Delete.Column("TargetUserId").FromTable("SystemAudits"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0120_AddUserAuthenticationStatePg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0120_AddUserAuthenticationStatePg.cs new file mode 100644 index 000000000..3b45bbb88 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0120_AddUserAuthenticationStatePg.cs @@ -0,0 +1,30 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(120)] + public class M0120_AddUserAuthenticationStatePg : Migration + { + public override void Up() + { + if (!Schema.Table("aspnetusers").Column("authenticationgeneration").Exists()) + Alter.Table("aspnetusers").AddColumn("authenticationgeneration").AsInt64().NotNullable().WithDefaultValue(0L); + + if (!Schema.Table("aspnetusers").Column("credentialsvalidafterutc").Exists()) + Alter.Table("aspnetusers").AddColumn("credentialsvalidafterutc").AsDateTime2().Nullable(); + + if (!Schema.Table("aspnetusers").Column("authenticationstatechangedon").Exists()) + Alter.Table("aspnetusers").AddColumn("authenticationstatechangedon").AsDateTime2().Nullable(); + } + + public override void Down() + { + if (Schema.Table("aspnetusers").Column("authenticationstatechangedon").Exists()) + Delete.Column("authenticationstatechangedon").FromTable("aspnetusers"); + if (Schema.Table("aspnetusers").Column("credentialsvalidafterutc").Exists()) + Delete.Column("credentialsvalidafterutc").FromTable("aspnetusers"); + if (Schema.Table("aspnetusers").Column("authenticationgeneration").Exists()) + Delete.Column("authenticationgeneration").FromTable("aspnetusers"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0121_AddUserSessionsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0121_AddUserSessionsPg.cs new file mode 100644 index 000000000..eb61c4d6d --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0121_AddUserSessionsPg.cs @@ -0,0 +1,72 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(121)] + public class M0121_AddUserSessionsPg : Migration + { + public override void Up() + { + if (Schema.Table("usersessions").Exists()) + return; + + Create.Table("usersessions") + .WithColumn("usersessionid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("userid").AsCustom("citext").NotNullable() + .WithColumn("departmentid").AsInt32().Nullable() + .WithColumn("authenticationgeneration").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("stateversion").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("clientapplication").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("clientinstanceidhash").AsCustom("citext").Nullable() + .WithColumn("devicename").AsCustom("citext").Nullable() + .WithColumn("devicetype").AsCustom("citext").Nullable() + .WithColumn("operatingsystem").AsCustom("citext").Nullable() + .WithColumn("browser").AsCustom("citext").Nullable() + .WithColumn("applicationversion").AsCustom("citext").Nullable() + .WithColumn("authenticationmethod").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("departmentssoconfigid").AsCustom("citext").Nullable() + .WithColumn("openiddictauthorizationid").AsCustom("citext").Nullable() + .WithColumn("webcookieticketkey").AsCustom("citext").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("lastactiveon").AsDateTime2().NotNullable() + .WithColumn("expireson").AsDateTime2().NotNullable() + .WithColumn("firstipaddress").AsCustom("citext").Nullable() + .WithColumn("lastipaddress").AsCustom("citext").Nullable() + .WithColumn("lastcountry").AsCustom("citext").Nullable() + .WithColumn("lastregion").AsCustom("citext").Nullable() + .WithColumn("lastcity").AsCustom("citext").Nullable() + .WithColumn("useragent").AsCustom("citext").Nullable() + .WithColumn("islegacyadopted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("revokedon").AsDateTime2().Nullable() + .WithColumn("revokedbyuserid").AsCustom("citext").Nullable() + .WithColumn("revocationreason").AsInt32().Nullable(); + + Create.Index("ix_usersessions_user_state_expiry_activity") + .OnTable("usersessions") + .OnColumn("userid").Ascending() + .OnColumn("state").Ascending() + .OnColumn("expireson").Ascending() + .OnColumn("lastactiveon").Descending(); + Create.Index("ix_usersessions_department_user_state") + .OnTable("usersessions") + .OnColumn("departmentid").Ascending() + .OnColumn("userid").Ascending() + .OnColumn("state").Ascending(); + Create.Index("ix_usersessions_revoked_expiry") + .OnTable("usersessions") + .OnColumn("revokedon").Ascending() + .OnColumn("expireson").Ascending(); + Execute.Sql("CREATE UNIQUE INDEX ux_usersessions_openiddictauthorizationid ON usersessions (openiddictauthorizationid) WHERE openiddictauthorizationid IS NOT NULL;"); + } + + public override void Down() + { + if (Schema.Table("usersessions").Exists()) + { + Execute.Sql("DROP INDEX IF EXISTS ux_usersessions_openiddictauthorizationid;"); + Delete.Table("usersessions"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0122_AddUserExternalIdentityLinksPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0122_AddUserExternalIdentityLinksPg.cs new file mode 100644 index 000000000..6baf0e678 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0122_AddUserExternalIdentityLinksPg.cs @@ -0,0 +1,53 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(122)] + public class M0122_AddUserExternalIdentityLinksPg : Migration + { + public override void Up() + { + if (Schema.Table("userexternalidentitylinks").Exists()) + return; + + Create.Table("userexternalidentitylinks") + .WithColumn("userexternalidentitylinkid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("userid").AsCustom("citext").NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("departmentmemberid").AsInt32().NotNullable() + .WithColumn("departmentssoconfigid").AsCustom("citext").NotNullable() + .WithColumn("providertype").AsInt32().NotNullable() + .WithColumn("issuer").AsCustom("citext").NotNullable() + .WithColumn("externalsubject").AsCustom("citext").NotNullable() + .WithColumn("linkmethod").AsInt32().NotNullable() + .WithColumn("emailatlink").AsCustom("citext").Nullable() + .WithColumn("isemailexternallymanaged").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("isactive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("linkedon").AsDateTime2().NotNullable() + .WithColumn("lastloginon").AsDateTime2().Nullable() + .WithColumn("unlinkedon").AsDateTime2().Nullable() + .WithColumn("unlinkedbyuserid").AsCustom("citext").Nullable(); + + Create.Index("ux_userexternalidentitylinks_config_subject") + .OnTable("userexternalidentitylinks") + .OnColumn("departmentssoconfigid").Ascending() + .OnColumn("externalsubject").Ascending() + .WithOptions().Unique(); + Create.Index("ux_userexternalidentitylinks_user_config") + .OnTable("userexternalidentitylinks") + .OnColumn("userid").Ascending() + .OnColumn("departmentssoconfigid").Ascending() + .WithOptions().Unique(); + Create.Index("ix_userexternalidentitylinks_department_member") + .OnTable("userexternalidentitylinks") + .OnColumn("departmentid").Ascending() + .OnColumn("departmentmemberid").Ascending(); + } + + public override void Down() + { + if (Schema.Table("userexternalidentitylinks").Exists()) + Delete.Table("userexternalidentitylinks"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0123_AddAuthenticationAuditContextPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0123_AddAuthenticationAuditContextPg.cs new file mode 100644 index 000000000..23d465a14 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0123_AddAuthenticationAuditContextPg.cs @@ -0,0 +1,28 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(123)] + public class M0123_AddAuthenticationAuditContextPg : Migration + { + public override void Up() + { + if (!Schema.Table("systemaudits").Column("targetuserid").Exists()) + Alter.Table("systemaudits").AddColumn("targetuserid").AsCustom("citext").Nullable(); + if (!Schema.Table("systemaudits").Column("sessionid").Exists()) + Alter.Table("systemaudits").AddColumn("sessionid").AsCustom("citext").Nullable(); + if (!Schema.Table("systemaudits").Column("correlationid").Exists()) + Alter.Table("systemaudits").AddColumn("correlationid").AsCustom("citext").Nullable(); + } + + public override void Down() + { + if (Schema.Table("systemaudits").Column("correlationid").Exists()) + Delete.Column("correlationid").FromTable("systemaudits"); + if (Schema.Table("systemaudits").Column("sessionid").Exists()) + Delete.Column("sessionid").FromTable("systemaudits"); + if (Schema.Table("systemaudits").Column("targetuserid").Exists()) + Delete.Column("targetuserid").FromTable("systemaudits"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs index f79bb9679..05a10e3a1 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs @@ -106,45 +106,6 @@ public IdentityUser GetUserByEmail(string email) return null; } - public void UpdateUsername(string oldUsername, string newUsername) - { - if (DataConfig.DatabaseType == DatabaseTypes.Postgres) - { - using (IDbConnection db = new NpgsqlConnection(DataConfig.CoreConnectionString)) - { - // username is citext, so equality is already case-insensitive and index-friendly. - db.Execute($"UPDATE public.aspnetusers SET username = @newUsername, normalizedusername = @newUsernameUpper WHERE username = @oldUsername", new { newUsername = newUsername, newUsernameUpper = newUsername.ToUpperInvariant(), oldUsername = oldUsername }); - } - } - else - { - using (IDbConnection db = new SqlConnection(DataConfig.CoreConnectionString)) - { - db.Execute($"UPDATE [AspNetUsers] SET [UserName] = @newUsername, [NormalizedUserName] = @newUsernameUpper WHERE UserName = @oldUsername", new { newUsername = newUsername, newUsernameUpper = newUsername.ToUpperInvariant(), oldUsername = oldUsername }); - } - } - } - - public void UpdateEmail(string userId, string newEmail) - { - if (DataConfig.DatabaseType == DatabaseTypes.Postgres) - { - using (IDbConnection db = new NpgsqlConnection(DataConfig.CoreConnectionString)) - { - // Keep normalizedemail in sync (ASP.NET Identity's FindByEmailAsync looks up by it); the - // SQL Server branch already does this. Without it, email lookups go stale after a change. - db.Execute($"UPDATE public.aspnetusers SET email = @newEmail, normalizedemail = @newEmailUpper WHERE id = @userId", new { userId = userId, newEmail = newEmail, newEmailUpper = newEmail?.ToUpperInvariant() }); - } - } - else - { - using (IDbConnection db = new SqlConnection(DataConfig.CoreConnectionString)) - { - db.Execute($"UPDATE [AspNetUsers] SET [Email] = @newEmail, [NormalizedEmail] = @newEmailUpper WHERE Id = @userId", new { userId = userId, newEmail = newEmail, newEmailUpper = newEmail?.ToUpperInvariant() }); - } - } - } - public void AddUserToRole(string userId, string roleId) { if (DataConfig.DatabaseType == DatabaseTypes.Postgres) @@ -568,13 +529,14 @@ public async Task ClearOutUserLoginAsync(string userId) public async Task CleanUpOIDCTokensByUserAsync(string userId) { + var affectedRows = 0; if (OidcConfig.DatabaseType == DatabaseTypes.Postgres) { using (IDbConnection db = new NpgsqlConnection(OidcConfig.ConnectionString)) { - var result = await db.ExecuteAsync(@"DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" = @userId", + affectedRows += await db.ExecuteAsync(@"DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" = @userId", new { userId = userId }); - await db.ExecuteAsync(@"DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" = @userId", + affectedRows += await db.ExecuteAsync(@"DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" = @userId", new { userId = userId }); } } @@ -582,16 +544,16 @@ await db.ExecuteAsync(@"DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject { using (IDbConnection db = new SqlConnection(OidcConfig.ConnectionString)) { - var result = await db.ExecuteAsync(@"DELETE FROM OpenIddictTokens + affectedRows += await db.ExecuteAsync(@"DELETE FROM OpenIddictTokens WHERE Subject = @userId", new { userId = userId }); - await db.ExecuteAsync(@"DELETE FROM OpenIddictAuthorizations + affectedRows += await db.ExecuteAsync(@"DELETE FROM OpenIddictAuthorizations WHERE Subject = @userId", new { userId = userId }); } } - return false; + return affectedRows > 0; } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index 148f458c7..57103be56 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -247,6 +247,8 @@ protected override void Load(ContainerBuilder builder) // SSO Repositories builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // UTF-8 / PostgreSQL-migration data maintenance builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index fc05cd518..b3a6dad66 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -259,6 +259,8 @@ protected override void Load(ContainerBuilder builder) // SSO Repositories builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // Workflow Repositories builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index 43eda2744..d7dfdbeed 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -234,6 +234,8 @@ protected override void Load(ContainerBuilder builder) // SSO Repositories builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index 74329c673..3a3775ffc 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -247,6 +247,8 @@ protected override void Load(ContainerBuilder builder) // SSO Repositories builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/UserExternalIdentityLinksRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UserExternalIdentityLinksRepository.cs new file mode 100644 index 000000000..6b1a3c70a --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/UserExternalIdentityLinksRepository.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public class UserExternalIdentityLinksRepository : RepositoryBase, IUserExternalIdentityLinksRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly IUnitOfWork _unitOfWork; + private readonly string _table; + private readonly bool _isPostgres; + + public UserExternalIdentityLinksRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _unitOfWork = unitOfWork; + _isPostgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = _isPostgres + ? $"{sqlConfiguration.SchemaName}.userexternalidentitylinks" + : $"{sqlConfiguration.SchemaName}.[UserExternalIdentityLinks]"; + } + + public Task GetActiveBySubjectAsync(string departmentSsoConfigId, string externalSubject) + { + var where = _isPostgres + ? "departmentssoconfigid = @ConfigId AND externalsubject = @ExternalSubject AND isactive = TRUE" + : "[DepartmentSsoConfigId] = @ConfigId AND [ExternalSubject] = @ExternalSubject AND [IsActive] = 1"; + return QuerySingleAsync(where, new { ConfigId = departmentSsoConfigId, ExternalSubject = externalSubject }); + } + + public Task GetActiveByUserAndConfigAsync(string userId, string departmentSsoConfigId) + { + var where = _isPostgres + ? "userid = @UserId AND departmentssoconfigid = @ConfigId AND isactive = TRUE" + : "[UserId] = @UserId AND [DepartmentSsoConfigId] = @ConfigId AND [IsActive] = 1"; + return QuerySingleAsync(where, new { UserId = userId, ConfigId = departmentSsoConfigId }); + } + + public async Task> GetActiveByUserAsync(string userId) + { + var where = _isPostgres ? "userid = @UserId AND isactive = TRUE" : "[UserId] = @UserId AND [IsActive] = 1"; + var links = await WithConnectionAsync(connection => connection.QueryAsync( + $"SELECT * FROM {_table} WHERE {where}", new { UserId = userId }, _unitOfWork?.Transaction)); + return links.ToList(); + } + + private Task QuerySingleAsync(string where, object parameters) => + WithConnectionAsync(connection => connection.QueryFirstOrDefaultAsync( + $"SELECT * FROM {_table} WHERE {where}", parameters, _unitOfWork?.Transaction)); + + private async Task WithConnectionAsync(Func> operation) + { + if (_unitOfWork?.Connection != null) + return await operation(_unitOfWork.CreateOrGetConnection()); + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await operation(connection); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/UserSessionsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UserSessionsRepository.cs new file mode 100644 index 000000000..5128060df --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/UserSessionsRepository.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public class UserSessionsRepository : RepositoryBase, IUserSessionsRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly IUnitOfWork _unitOfWork; + private readonly string _table; + private readonly bool _isPostgres; + + public UserSessionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _unitOfWork = unitOfWork; + _isPostgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = _isPostgres + ? $"{sqlConfiguration.SchemaName}.usersessions" + : $"{sqlConfiguration.SchemaName}.[UserSessions]"; + } + + public async Task> GetActiveByUserAsync(string userId, DateTime utcNow) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE userid = @UserId AND state = @State AND expireson > @UtcNow ORDER BY lastactiveon DESC" + : $"SELECT * FROM {_table} WHERE [UserId] = @UserId AND [State] = @State AND [ExpiresOn] > @UtcNow ORDER BY [LastActiveOn] DESC"; + + var sessions = await WithConnectionAsync(connection => connection.QueryAsync( + sql, new { UserId = userId, State = (int)UserSessionState.Active, UtcNow = utcNow }, _unitOfWork?.Transaction)); + return sessions.ToList(); + } + + public Task GetByAuthorizationIdAsync(string authorizationId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE openiddictauthorizationid = @AuthorizationId" + : $"SELECT * FROM {_table} WHERE [OpenIddictAuthorizationId] = @AuthorizationId"; + return WithConnectionAsync(connection => connection.QueryFirstOrDefaultAsync( + sql, new { AuthorizationId = authorizationId }, _unitOfWork?.Transaction)); + } + + public Task TouchAsync(string sessionId, DateTime occurredOn, DateTime writeBefore, string ipAddress, + string country, string region, string city, string userAgent, CancellationToken cancellationToken) + { + var sql = _isPostgres + ? $@"UPDATE {_table} SET lastactiveon = @OccurredOn, lastipaddress = @IpAddress, + lastcountry = @Country, lastregion = @Region, lastcity = @City, useragent = @UserAgent + WHERE usersessionid = @SessionId AND state = @State AND lastactiveon <= @WriteBefore" + : $@"UPDATE {_table} SET [LastActiveOn] = @OccurredOn, [LastIpAddress] = @IpAddress, + [LastCountry] = @Country, [LastRegion] = @Region, [LastCity] = @City, [UserAgent] = @UserAgent + WHERE [UserSessionId] = @SessionId AND [State] = @State AND [LastActiveOn] <= @WriteBefore"; + + return ExecuteAsync(sql, new + { + SessionId = sessionId, + OccurredOn = occurredOn, + WriteBefore = writeBefore, + IpAddress = ipAddress, + Country = country, + Region = region, + City = city, + UserAgent = userAgent, + State = (int)UserSessionState.Active + }, cancellationToken); + } + + public Task UpdateDepartmentAsync(string targetUserId, string sessionId, int departmentId, + CancellationToken cancellationToken) + { + var sql = _isPostgres + ? $@"UPDATE {_table} SET departmentid = @DepartmentId, stateversion = stateversion + 1 + WHERE userid = @TargetUserId AND usersessionid = @SessionId AND state = @ActiveState" + : $@"UPDATE {_table} SET [DepartmentId] = @DepartmentId, [StateVersion] = [StateVersion] + 1 + WHERE [UserId] = @TargetUserId AND [UserSessionId] = @SessionId AND [State] = @ActiveState"; + + return ExecuteAsync(sql, new + { + TargetUserId = targetUserId, + SessionId = sessionId, + DepartmentId = departmentId, + ActiveState = (int)UserSessionState.Active + }, cancellationToken); + } + + public Task RevokeAsync(string targetUserId, string sessionId, string actorUserId, int reason, + DateTime revokedOn, CancellationToken cancellationToken) + { + var predicate = _isPostgres + ? "userid = @TargetUserId AND usersessionid = @SessionId" + : "[UserId] = @TargetUserId AND [UserSessionId] = @SessionId"; + return RevokeWhereAsync(predicate, new { TargetUserId = targetUserId, SessionId = sessionId }, actorUserId, reason, revokedOn, cancellationToken); + } + + public Task RevokeOthersAsync(string userId, string currentSessionId, int reason, + DateTime revokedOn, CancellationToken cancellationToken) + { + var predicate = _isPostgres + ? "userid = @TargetUserId AND usersessionid <> @CurrentSessionId" + : "[UserId] = @TargetUserId AND [UserSessionId] <> @CurrentSessionId"; + return RevokeWhereAsync(predicate, new { TargetUserId = userId, CurrentSessionId = currentSessionId }, userId, reason, revokedOn, cancellationToken); + } + + public Task RevokeAllAsync(string targetUserId, string actorUserId, int reason, + DateTime revokedOn, CancellationToken cancellationToken) + { + var predicate = _isPostgres ? "userid = @TargetUserId" : "[UserId] = @TargetUserId"; + return RevokeWhereAsync(predicate, new { TargetUserId = targetUserId }, actorUserId, reason, revokedOn, cancellationToken); + } + + public Task RevokeDepartmentAsync(string targetUserId, int departmentId, int reason, + DateTime revokedOn, CancellationToken cancellationToken) + { + var predicate = _isPostgres + ? "userid = @TargetUserId AND departmentid = @DepartmentId" + : "[UserId] = @TargetUserId AND [DepartmentId] = @DepartmentId"; + return RevokeWhereAsync(predicate, new { TargetUserId = targetUserId, DepartmentId = departmentId }, targetUserId, reason, revokedOn, cancellationToken); + } + + public Task PurgeInactiveBeforeAsync(DateTime historyBeforeUtc, CancellationToken cancellationToken) + { + var sql = _isPostgres + ? $@"DELETE FROM {_table} + WHERE (state <> @ActiveState AND revokedon IS NOT NULL AND revokedon < @HistoryBeforeUtc) + OR expireson < @HistoryBeforeUtc" + : $@"DELETE FROM {_table} + WHERE ([State] <> @ActiveState AND [RevokedOn] IS NOT NULL AND [RevokedOn] < @HistoryBeforeUtc) + OR [ExpiresOn] < @HistoryBeforeUtc"; + + return ExecuteAsync(sql, new + { + ActiveState = (int)UserSessionState.Active, + HistoryBeforeUtc = historyBeforeUtc + }, cancellationToken); + } + + private Task RevokeWhereAsync(string predicate, object values, string actorUserId, int reason, + DateTime revokedOn, CancellationToken cancellationToken) + { + var sql = _isPostgres + ? $@"UPDATE {_table} SET state = @RevokedState, stateversion = stateversion + 1, + revokedon = @RevokedOn, revokedbyuserid = @ActorUserId, revocationreason = @Reason + WHERE {predicate} AND state = @ActiveState" + : $@"UPDATE {_table} SET [State] = @RevokedState, [StateVersion] = [StateVersion] + 1, + [RevokedOn] = @RevokedOn, [RevokedByUserId] = @ActorUserId, [RevocationReason] = @Reason + WHERE {predicate} AND [State] = @ActiveState"; + + var parameters = new DynamicParameters(values); + parameters.Add("RevokedState", (int)UserSessionState.Revoked); + parameters.Add("ActiveState", (int)UserSessionState.Active); + parameters.Add("RevokedOn", revokedOn); + parameters.Add("ActorUserId", actorUserId); + parameters.Add("Reason", reason); + return ExecuteAsync(sql, parameters, cancellationToken); + } + + private Task ExecuteAsync(string sql, object parameters, CancellationToken cancellationToken) + { + return WithConnectionAsync(connection => connection.ExecuteAsync( + new Dapper.CommandDefinition(sql, parameters, _unitOfWork?.Transaction, cancellationToken: cancellationToken))); + } + + private async Task WithConnectionAsync(Func> operation) + { + if (_unitOfWork?.Connection != null) + return await operation(_unitOfWork.CreateOrGetConnection()); + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await operation(connection); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ClientSessionMetadataParserTests.cs b/Tests/Resgrid.Tests/Services/ClientSessionMetadataParserTests.cs new file mode 100644 index 000000000..720c95194 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ClientSessionMetadataParserTests.cs @@ -0,0 +1,35 @@ +using NUnit.Framework; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ClientSessionMetadataParserTests + { + private readonly ClientSessionMetadataParser _parser = new ClientSessionMetadataParser(); + + [Test] + public void parses_a_modern_windows_edge_session() + { + var result = _parser.Parse("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0"); + + Assert.That(result.DeviceType, Is.EqualTo("Computer")); + Assert.That(result.DeviceName, Is.EqualTo("Windows 10/11 Computer")); + Assert.That(result.OperatingSystem, Is.EqualTo("Windows 10/11")); + Assert.That(result.Browser, Is.EqualTo("Edge 126.0.0.0")); + } + + [Test] + public void explicit_mobile_app_metadata_wins_over_user_agent_fallbacks() + { + var result = _parser.Parse("okhttp/4.12", "Engine 4", "Tablet", "Android 15", "Native", + "9.2.1"); + + Assert.That(result.DeviceName, Is.EqualTo("Engine 4")); + Assert.That(result.DeviceType, Is.EqualTo("Tablet")); + Assert.That(result.OperatingSystem, Is.EqualTo("Android 15")); + Assert.That(result.Browser, Is.EqualTo("Native")); + Assert.That(result.ApplicationVersion, Is.EqualTo("9.2.1")); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs index 3b153058b..745a37f86 100644 --- a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs @@ -59,6 +59,66 @@ protected with_the_communication_service() [TestFixture] public class when_sending_a_communication : with_the_communication_service { + [Test] + public async Task weather_alerts_use_email_and_push_only() + { + var message = new Message + { + MessageId = 42, + Type = (int)MessageTypes.WeatherAlert, + Subject = "Tornado Warning", + Body = "Take shelter immediately.", + ReceivingUserId = TestData.Users.TestUser1Id, + SystemGenerated = true + }; + var profile = new UserProfile + { + UserId = TestData.Users.TestUser1Id, + SendNotificationSms = true, + SendNotificationEmail = true, + SendNotificationPush = true, + MobileNumberVerified = true, + EmailVerified = true + }; + + await _communicationService.SendMessageAsync(message, "Weather Alert System", null, 1, profile); + + _smsServiceMock.Verify(m => m.SendMessageAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + _emailServiceMock.Verify(m => m.SendMessageAsync( + message, "Weather Alert System", 1, profile, message.ReceivingUser), Times.Once()); + _pushServiceMock.Verify(m => m.PushMessage( + It.IsAny(), TestData.Users.TestUser1Id, profile), Times.Once()); + _chatbotOutboundServiceMock.Verify(m => m.SendToUserAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + } + + [Test] + public async Task normal_messages_are_still_sent_to_chatbot() + { + var message = new Message + { + MessageId = 43, + Type = (int)MessageTypes.Normal, + Subject = "Station update", + Body = "Briefing starts at 18:00.", + ReceivingUserId = TestData.Users.TestUser1Id, + SystemGenerated = true + }; + var profile = new UserProfile { UserId = TestData.Users.TestUser1Id }; + + await _communicationService.SendMessageAsync(message, "System", null, 1, profile); + + _chatbotOutboundServiceMock.Verify(m => m.SendToUserAsync( + TestData.Users.TestUser1Id, + 1, + It.Is(outbound => + outbound.Type == ChatbotOutboundType.Message && + outbound.Title == message.Subject && + outbound.Body == message.Body && + outbound.ReferenceId == message.MessageId.ToString())), Times.Once()); + } + //[Test] public async Task should_be_able_to_send_message() { diff --git a/Tests/Resgrid.Tests/Services/DepartmentSettingsServicePasswordResetTests.cs b/Tests/Resgrid.Tests/Services/DepartmentSettingsServicePasswordResetTests.cs new file mode 100644 index 000000000..ca77d7dfa --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DepartmentSettingsServicePasswordResetTests.cs @@ -0,0 +1,94 @@ +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + [NonParallelizable] + public class DepartmentSettingsServicePasswordResetTests + { + private Mock _repository; + private Mock _cacheProvider; + private DepartmentSettingsService _service; + private bool _originalCacheEnabled; + + [SetUp] + public void SetUp() + { + _originalCacheEnabled = SystemBehaviorConfig.CacheEnabled; + SystemBehaviorConfig.CacheEnabled = false; + _repository = new Mock(); + _cacheProvider = new Mock(); + _service = new DepartmentSettingsService( + _repository.Object, + Mock.Of(), + Mock.Of(), + _cacheProvider.Object); + } + + [TearDown] + public void TearDown() + { + SystemBehaviorConfig.CacheEnabled = _originalCacheEnabled; + } + + [Test] + public async Task GetRequirePasswordResetViaEmailAsync_MissingSetting_PreservesDirectResetDefault() + { + var enabled = await _service.GetRequirePasswordResetViaEmailAsync(7); + + enabled.Should().BeFalse(); + } + + [Test] + public async Task GetRequirePasswordResetViaEmailAsync_EnabledSetting_ReturnsTrue() + { + _repository + .Setup(repository => repository.GetDepartmentSettingByIdTypeAsync( + 7, + DepartmentSettingTypes.RequirePasswordResetViaEmail)) + .ReturnsAsync(new DepartmentSetting + { + DepartmentId = 7, + SettingType = (int)DepartmentSettingTypes.RequirePasswordResetViaEmail, + Setting = "true" + }); + + var enabled = await _service.GetRequirePasswordResetViaEmailAsync(7); + + enabled.Should().BeTrue(); + } + + [Test] + public async Task SaveOrUpdateSettingAsync_ResetModeWrite_InvalidatesItsCache() + { + _repository + .Setup(repository => repository.SaveOrUpdateAsync( + It.IsAny(), + It.IsAny(), + false)) + .ReturnsAsync((DepartmentSetting setting, CancellationToken cancellationToken, bool firstLevelOnly) => setting); + _cacheProvider + .Setup(provider => provider.RemoveAsync("DSetRequirePasswordResetViaEmail_7")) + .ReturnsAsync(true); + + await _service.SaveOrUpdateSettingAsync( + 7, + "true", + DepartmentSettingTypes.RequirePasswordResetViaEmail); + + _cacheProvider.Verify( + provider => provider.RemoveAsync("DSetRequirePasswordResetViaEmail_7"), + Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/DepartmentSsoServiceTests.cs b/Tests/Resgrid.Tests/Services/DepartmentSsoServiceTests.cs index 0fd2f5341..fbfa80f17 100644 --- a/Tests/Resgrid.Tests/Services/DepartmentSsoServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DepartmentSsoServiceTests.cs @@ -48,7 +48,8 @@ public void SetUp() new Mock().Object, new Mock().Object, _encryptionService.Object, - _cacheProvider.Object); + _cacheProvider.Object, + new Mock().Object); } [Test] diff --git a/Tests/Resgrid.Tests/Services/ExternalIdentityLinkServiceTests.cs b/Tests/Resgrid.Tests/Services/ExternalIdentityLinkServiceTests.cs new file mode 100644 index 000000000..4a411d1d4 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ExternalIdentityLinkServiceTests.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ExternalIdentityLinkServiceTests + { + [Test] + public async Task durable_link_with_missing_configuration_fails_closed_for_local_login() + { + var links = new Mock(); + var configs = new Mock(); + var members = new Mock(); + links.Setup(x => x.GetActiveByUserAsync("user-1")).ReturnsAsync(new List + { + new UserExternalIdentityLink + { + UserId = "user-1", + DepartmentId = 9, + DepartmentSsoConfigId = "missing-config", + IsActive = true + } + }); + configs.Setup(x => x.GetAllByDepartmentIdAsync(9)) + .ReturnsAsync(new List()); + + var service = new ExternalIdentityLinkService(links.Object, configs.Object, members.Object); + + Assert.That(await service.IsLocalLoginAllowedAsync("user-1", 9), Is.False); + Assert.That(await service.IsLocalLoginAllowedAsync("user-1"), Is.False); + } + + [Test] + public async Task enabled_link_must_explicitly_allow_local_login() + { + var links = new Mock(); + var configs = new Mock(); + var members = new Mock(); + links.Setup(x => x.GetActiveByUserAsync("user-1")).ReturnsAsync(new List + { + new UserExternalIdentityLink + { + UserId = "user-1", + DepartmentId = 9, + DepartmentSsoConfigId = "config-1", + IsActive = true + } + }); + configs.Setup(x => x.GetAllByDepartmentIdAsync(9)).ReturnsAsync(new[] + { + new DepartmentSsoConfig + { + DepartmentSsoConfigId = "config-1", + DepartmentId = 9, + IsEnabled = true, + AllowLocalLogin = false + } + }); + + var service = new ExternalIdentityLinkService(links.Object, configs.Object, members.Object); + + Assert.That(await service.IsLocalLoginAllowedAsync("user-1", 9), Is.False); + } + + [Test] + public async Task legacy_link_in_any_department_keeps_credentials_and_email_sso_managed() + { + var links = new Mock(); + var configs = new Mock(); + var members = new Mock(); + links.Setup(x => x.GetActiveByUserAsync("user-1")) + .ReturnsAsync(new List()); + members.Setup(x => x.GetAllDepartmentMemberByUserIdAsync("user-1")) + .ReturnsAsync(new[] + { + new DepartmentMember + { + UserId = "user-1", + DepartmentId = 22, + ExternalSsoId = "legacy-subject" + } + }); + + var service = new ExternalIdentityLinkService(links.Object, configs.Object, members.Object); + var state = await service.GetSsoManagementStateAsync("user-1"); + + Assert.That(state.IsSsoManaged, Is.True); + Assert.That(state.IsEmailExternallyManaged, Is.True); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/LocalIpLocationProviderTests.cs b/Tests/Resgrid.Tests/Services/LocalIpLocationProviderTests.cs new file mode 100644 index 000000000..0ab59f32f --- /dev/null +++ b/Tests/Resgrid.Tests/Services/LocalIpLocationProviderTests.cs @@ -0,0 +1,42 @@ +using System.IO; +using System.Threading.Tasks; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + [NonParallelizable] + public class LocalIpLocationProviderTests + { + [Test] + public async Task longest_matching_local_cidr_provides_only_coarse_location() + { + var originalPath = SessionSecurityConfig.IpLocationDatabasePath; + var path = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(path, """ + [ + { "network": "203.0.0.0/16", "country": "US", "region": "Broad" }, + { "network": "203.0.113.0/24", "country": "US", "region": "California", "city": "Example City" } + ] + """); + SessionSecurityConfig.IpLocationDatabasePath = path; + var provider = new LocalIpLocationProvider(); + + var result = await provider.GetApproximateLocationAsync("203.0.113.9"); + + Assert.That(result.Country, Is.EqualTo("US")); + Assert.That(result.Region, Is.EqualTo("California")); + Assert.That(result.City, Is.EqualTo("Example City")); + } + finally + { + SessionSecurityConfig.IpLocationDatabasePath = originalPath; + File.Delete(path); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/PasswordRecoveryServiceTests.cs b/Tests/Resgrid.Tests/Services/PasswordRecoveryServiceTests.cs new file mode 100644 index 000000000..8ed287481 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/PasswordRecoveryServiceTests.cs @@ -0,0 +1,87 @@ +using System; +using System.Threading.Tasks; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model.Providers; +using Resgrid.Model.Security; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class PasswordRecoveryServiceTests + { + [Test] + public async Task issue_creates_an_opaque_short_lived_grant_without_putting_the_token_in_the_cache_key() + { + var cache = new Mock(); + cache.Setup(x => x.IncrementAsync(It.IsAny(), It.IsAny())).ReturnsAsync(1); + string storedKey = null; + string storedValue = null; + TimeSpan storedLifetime = default; + cache.Setup(x => x.SetStringAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((key, value, lifetime) => + { + storedKey = key; + storedValue = value; + storedLifetime = lifetime; + }) + .ReturnsAsync(true); + var service = new PasswordRecoveryService(cache.Object); + + var result = await service.IssueAsync("user-1", "person@example.test", "192.0.2.1", 4, "stamp"); + + Assert.That(result.Issued, Is.True); + Assert.That(result.Token, Has.Length.EqualTo(43)); + Assert.That(result.Token, Does.Match("^[A-Za-z0-9_-]+$")); + Assert.That(storedKey, Does.Not.Contain(result.Token)); + Assert.That(storedLifetime, Is.EqualTo(TimeSpan.FromMinutes( + Math.Max(5, SessionSecurityConfig.PublicResetLinkLifetimeMinutes)))); + var request = JsonConvert.DeserializeObject(storedValue); + Assert.That(request.UserId, Is.EqualTo("user-1")); + Assert.That(request.Email, Is.EqualTo("person@example.test")); + Assert.That(request.AuthenticationGeneration, Is.EqualTo(4)); + Assert.That(request.SecurityStampHash, Has.Length.EqualTo(64)); + } + + [Test] + public async Task unknown_accounts_are_rate_limited_but_never_receive_a_persisted_grant() + { + var cache = new Mock(); + cache.Setup(x => x.IncrementAsync(It.IsAny(), It.IsAny())).ReturnsAsync(1); + var service = new PasswordRecoveryService(cache.Object); + + var result = await service.IssueAsync(null, "unknown@example.test", "192.0.2.2", 0, null); + + Assert.That(result.Issued, Is.False); + Assert.That(result.Token, Is.Null); + cache.Verify(x => x.SetStringAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + cache.Verify(x => x.IncrementAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public async Task consume_is_single_use_even_while_the_request_record_still_exists() + { + var cache = new Mock(); + var request = new PasswordRecoveryRequest + { + UserId = "user-1", + Email = "person@example.test", + CreatedOn = DateTime.UtcNow, + ExpiresOn = DateTime.UtcNow.AddMinutes(10) + }; + cache.Setup(x => x.GetStringAsync(It.IsAny())) + .ReturnsAsync(JsonConvert.SerializeObject(request)); + cache.SetupSequence(x => x.IncrementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(1) + .ReturnsAsync(2); + var service = new PasswordRecoveryService(cache.Object); + + Assert.That(await service.TryConsumeAsync("opaque-token"), Is.True); + Assert.That(await service.TryConsumeAsync("opaque-token"), Is.False); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/UserSessionServiceTests.cs b/Tests/Resgrid.Tests/Services/UserSessionServiceTests.cs new file mode 100644 index 000000000..7723feb28 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/UserSessionServiceTests.cs @@ -0,0 +1,223 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Identity; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class UserSessionServiceTests + { + private Mock _sessions; + private Mock _users; + private Mock _identity; + private Mock _departments; + private Mock _departmentSso; + private UserSessionService _service; + + [SetUp] + public void SetUp() + { + _sessions = new Mock(); + _users = new Mock(); + _identity = new Mock(); + _departments = new Mock(); + _departmentSso = new Mock(); + _service = new UserSessionService(_sessions.Object, _users.Object, _identity.Object, + _departments.Object, _departmentSso.Object, new ClientSessionMetadataParser(), Mock.Of()); + SessionSecurityConfig.LegacyAdoptionEnabled = true; + SessionSecurityConfig.RequireSessionClaimForCredentialsIssuedAfterUtc = string.Empty; + SessionSecurityConfig.DepartmentSessionPolicyEnforcementAfterUtc = string.Empty; + } + + [Test] + public async Task rejects_credentials_at_or_before_the_account_cutoff() + { + var cutoff = DateTime.UtcNow; + _users.Setup(x => x.GetByIdAsync("user-1")).ReturnsAsync(new IdentityUser + { + Id = "user-1", + CredentialsValidAfterUtc = cutoff + }); + + var result = await _service.ValidateAsync(new SessionPrincipalContext + { + UserId = "user-1", + CredentialIssuedOn = cutoff + }); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.FailureCode, Is.EqualTo("credential_cutoff")); + } + + [Test] + public async Task accepts_a_pre_feature_credential_for_lazy_adoption() + { + _users.Setup(x => x.GetByIdAsync("user-1")).ReturnsAsync(new IdentityUser + { + Id = "user-1", + AuthenticationGeneration = 0 + }); + + var result = await _service.ValidateAsync(new SessionPrincipalContext + { + UserId = "user-1", + CredentialIssuedOn = DateTime.UtcNow.AddDays(-1) + }); + + Assert.That(result.IsValid, Is.True); + Assert.That(result.CanAdoptLegacy, Is.True); + } + + [Test] + public async Task rejects_a_revoked_individual_session_immediately() + { + _users.Setup(x => x.GetByIdAsync("user-1")).ReturnsAsync(new IdentityUser + { + Id = "user-1", + AuthenticationGeneration = 4 + }); + _sessions.Setup(x => x.GetByIdAsync((object)"session-1")).ReturnsAsync(new UserSession + { + UserSessionId = "session-1", + UserId = "user-1", + AuthenticationGeneration = 4, + State = (int)UserSessionState.Revoked, + ExpiresOn = DateTime.UtcNow.AddHours(1) + }); + + var result = await _service.ValidateAsync(new SessionPrincipalContext + { + UserId = "user-1", + SessionId = "session-1", + AuthenticationGeneration = 4, + CredentialIssuedOn = DateTime.UtcNow.AddMinutes(-1) + }); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.FailureCode, Is.EqualTo("session_revoked")); + } + + [Test] + public async Task revoke_all_rotates_account_state_and_removes_oidc_credentials() + { + var user = new IdentityUser {Id = "user-1", AuthenticationGeneration = 7, SecurityStamp = "old"}; + var cutoff = DateTime.UtcNow; + _users.Setup(x => x.GetByIdAsync("user-1")).ReturnsAsync(user); + _users.Setup(x => x.UpdateAsync(user, It.IsAny())).ReturnsAsync(true); + _sessions.Setup(x => x.RevokeAllAsync("user-1", "admin-1", (int)UserSessionRevocationReason.PasswordReset, + cutoff, It.IsAny())).ReturnsAsync(3); + + var result = await _service.RevokeAllAsync("admin-1", "user-1", + UserSessionRevocationReason.PasswordReset, cutoff); + + Assert.That(user.AuthenticationGeneration, Is.EqualTo(8)); + Assert.That(user.CredentialsValidAfterUtc, Is.EqualTo(cutoff)); + Assert.That(user.SecurityStamp, Is.Not.EqualTo("old")); + Assert.That(result.RevokedSessionCount, Is.EqualTo(3)); + _identity.Verify(x => x.CleanUpOIDCTokensByUserAsync("user-1"), Times.Once); + } + + [Test] + public async Task moving_a_session_requires_an_active_membership_and_owned_active_session() + { + _departments.Setup(x => x.GetDepartmentMemberAsync("user-1", 22, true)) + .ReturnsAsync(new DepartmentMember {UserId = "user-1", DepartmentId = 22}); + _sessions.Setup(x => x.UpdateDepartmentAsync("user-1", "session-1", 22, + It.IsAny())).ReturnsAsync(1); + + var moved = await _service.MoveSessionToDepartmentAsync("user-1", "session-1", 22); + + Assert.That(moved, Is.True); + _sessions.Verify(x => x.UpdateDepartmentAsync("user-1", "session-1", 22, + It.IsAny()), Times.Once); + } + + [Test] + public void policy_gate_denies_a_new_session_at_the_department_limit() + { + var gate = DateTime.UtcNow.AddMinutes(-5); + SessionSecurityConfig.DepartmentSessionPolicyEnforcementAfterUtc = gate.ToString("O"); + _departments.Setup(x => x.GetDepartmentMemberAsync("user-1", 22, true)) + .ReturnsAsync(new DepartmentMember {UserId = "user-1", DepartmentId = 22}); + _departmentSso.Setup(x => x.GetSecurityPolicyForDepartmentAsync(22, It.IsAny())) + .ReturnsAsync(new DepartmentSecurityPolicy {DepartmentId = 22, MaxConcurrentSessions = 1}); + _sessions.Setup(x => x.GetActiveByUserAsync("user-1", It.IsAny())) + .ReturnsAsync(new[] + { + new UserSession {UserId = "user-1", DepartmentId = 22, CreatedOn = gate.AddMinutes(1)} + }); + + Assert.ThrowsAsync(() => _service.CreateSessionAsync(new SessionIssueContext + { + UserId = "user-1", + DepartmentId = 22, + ExpiresOn = DateTime.UtcNow.AddHours(1) + })); + } + + [Test] + public void session_creation_denies_an_inactive_department_membership() + { + _departments.Setup(x => x.GetDepartmentMemberAsync("user-1", 22, true)) + .ReturnsAsync(new DepartmentMember {UserId = "user-1", DepartmentId = 22, IsDisabled = true}); + + var exception = Assert.ThrowsAsync(() => + _service.CreateSessionAsync(new SessionIssueContext + { + UserId = "user-1", + DepartmentId = 22, + ExpiresOn = DateTime.UtcNow.AddHours(1) + })); + + Assert.That(exception.FailureCode, Is.EqualTo("membership_inactive")); + } + + [Test] + public async Task policy_gate_rejects_an_idle_managed_session() + { + var gate = DateTime.UtcNow.AddHours(-2); + SessionSecurityConfig.DepartmentSessionPolicyEnforcementAfterUtc = gate.ToString("O"); + _users.Setup(x => x.GetByIdAsync("user-1")).ReturnsAsync(new IdentityUser + { + Id = "user-1", + AuthenticationGeneration = 1 + }); + _sessions.Setup(x => x.GetByIdAsync((object)"session-1")).ReturnsAsync(new UserSession + { + UserSessionId = "session-1", + UserId = "user-1", + DepartmentId = 22, + AuthenticationGeneration = 1, + State = (int)UserSessionState.Active, + CreatedOn = gate.AddMinutes(1), + LastActiveOn = DateTime.UtcNow.AddMinutes(-31), + ExpiresOn = DateTime.UtcNow.AddHours(1) + }); + _departments.Setup(x => x.GetDepartmentMemberAsync("user-1", 22, true)) + .ReturnsAsync(new DepartmentMember {UserId = "user-1", DepartmentId = 22}); + _departmentSso.Setup(x => x.GetSecurityPolicyForDepartmentAsync(22, It.IsAny())) + .ReturnsAsync(new DepartmentSecurityPolicy {DepartmentId = 22, SessionTimeoutMinutes = 30}); + + var result = await _service.ValidateAsync(new SessionPrincipalContext + { + UserId = "user-1", + SessionId = "session-1", + AuthenticationGeneration = 1, + DepartmentId = 22, + CredentialIssuedOn = DateTime.UtcNow.AddMinutes(-40) + }); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.FailureCode, Is.EqualTo("session_idle_timeout")); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/ConnectControllerSsoTests.cs b/Tests/Resgrid.Tests/Web/Services/ConnectControllerSsoTests.cs index 1acfb5655..4044cc5bd 100644 --- a/Tests/Resgrid.Tests/Web/Services/ConnectControllerSsoTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/ConnectControllerSsoTests.cs @@ -50,7 +50,9 @@ public void SetUp() _systemAuditsService.Object, _ssoService.Object, _encryptionService.Object, - _cacheProvider.Object) + _cacheProvider.Object, + Mock.Of(), + Mock.Of()) { ControllerContext = new ControllerContext { HttpContext = httpContext } }; diff --git a/Tools/Resgrid.Console/Commands/ResetPasswordCommand.cs b/Tools/Resgrid.Console/Commands/ResetPasswordCommand.cs index 9ff4e2e13..007edd915 100644 --- a/Tools/Resgrid.Console/Commands/ResetPasswordCommand.cs +++ b/Tools/Resgrid.Console/Commands/ResetPasswordCommand.cs @@ -7,13 +7,18 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Resgrid.Console.Models; +using Resgrid.Model; +using Resgrid.Model.Services; namespace Resgrid.Console.Commands { public sealed class ResetPasswordCommand( IConfiguration configuration, ILogger logger, - UserManager userManager) : ICommandService + UserManager userManager, + IUserSessionService userSessionService, + IExternalIdentityLinkService externalIdentityLinkService, + ISystemAuditsService systemAuditsService) : ICommandService { private string UserId => GetConfigurationValue("UserId"); @@ -37,11 +42,37 @@ public async Task ExecuteMainAsync(string[] args, CancellationToken ca return ExitCode.Failed; } + var managementState = await externalIdentityLinkService.GetSsoManagementStateAsync(user.Id, cancellationToken); + if (managementState.IsSsoManaged) + { + logger.LogError("The account is SSO-managed. Unlink it through the audited administrative process before resetting a local password."); + return ExitCode.Failed; + } + + var changedOn = DateTime.UtcNow; + user.AuthenticationGeneration++; + user.CredentialsValidAfterUtc = changedOn; + user.AuthenticationStateChangedOn = changedOn; var token = await userManager.GeneratePasswordResetTokenAsync(user); var result = await userManager.ResetPasswordAsync(user, token, Password); if (result.Succeeded) + { + await userSessionService.RevokeAllAfterCredentialChangeAsync(user.Id, user.Id, + UserSessionRevocationReason.PasswordReset, changedOn, cancellationToken); + await systemAuditsService.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Console, + Type = (int)SystemAuditTypes.PasswordResetByAdministrator, + UserId = user.Id, + TargetUserId = user.Id, + Successful = true, + ServerName = Environment.MachineName, + LoggedOn = changedOn, + Data = "Console password reset completed; all authentication sessions and tokens revoked." + }, cancellationToken); logger.LogInformation("Successfully Reset the Password"); + } else { logger.LogError("Failed to reset the Password: " + result.Errors.FirstOrDefault()?.Description); diff --git a/Web/Resgrid.Web.Eventing/Hubs/EventingHub.cs b/Web/Resgrid.Web.Eventing/Hubs/EventingHub.cs index 4ca24a8c0..798c82736 100644 --- a/Web/Resgrid.Web.Eventing/Hubs/EventingHub.cs +++ b/Web/Resgrid.Web.Eventing/Hubs/EventingHub.cs @@ -1,7 +1,8 @@ -using System.Threading.Tasks; -using CommonServiceLocator; +using System.Security.Claims; +using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; +using OpenIddict.Validation.AspNetCore; using Resgrid.Model.Services; namespace Resgrid.Web.Eventing.Hubs @@ -9,167 +10,111 @@ namespace Resgrid.Web.Eventing.Hubs public interface IEventingHub { Task Connect(int departmentId); - Task SubscribeToDepartmentLink(int linkId); - Task UnsubscribeToDepartmentLink(int linkId); - - Task PersonnelStatusUpdated(int departmentId, int id); - - Task PersonnelStaffingUpdated(int departmentId, int id); - - Task UnitStatusUpdated(int departmentId, int id); - - Task CallsUpdated(int departmentId, int id); - - Task DepartmentUpdated(int departmentId); - Task SubscribeToCall(int callId); - Task UnsubscribeToCall(int callId); - - Task CallDataUpdated(int callId); - - Task CallAdded(int departmentId, int id); - - Task CallClosed(int departmentId, int id); - - Task WeatherAlertReceived(int departmentId, string alertId); - - Task WeatherAlertExpired(int departmentId, string alertId); - - Task WeatherAlertUpdated(int departmentId, string alertId); } - [AllowAnonymous] + /// + /// Authenticated subscription-only hub for general department events. Events are published + /// by the server-side worker through IHubContext; callers cannot manufacture broadcasts. + /// + [Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] public class EventingHub : Hub { private readonly IDepartmentLinksService _departmentLinksService; + private readonly ICallsService _callsService; - public EventingHub() + public EventingHub(IDepartmentLinksService departmentLinksService, ICallsService callsService) { - _departmentLinksService = ServiceLocator.Current.GetInstance(); + _departmentLinksService = departmentLinksService; + _callsService = callsService; + } + + private int GetDepartmentId() + { + var claim = Context.User?.FindFirst(ClaimTypes.PrimaryGroupSid); + return claim != null && int.TryParse(claim.Value, out var departmentId) ? departmentId : 0; } public async Task Connect(int departmentId) { - await Groups.AddToGroupAsync(Context.ConnectionId, departmentId.ToString()); + var authenticatedDepartmentId = GetDepartmentId(); + if (authenticatedDepartmentId <= 0 || departmentId != authenticatedDepartmentId) + throw new HubException("Not authorized for this department."); + await Groups.AddToGroupAsync(Context.ConnectionId, authenticatedDepartmentId.ToString()); await Clients.Caller.SendAsync("onConnected", Context.ConnectionId); } public async Task SubscribeToDepartmentLink(int linkId) { var link = await _departmentLinksService.GetLinkByIdAsync(linkId); + var linkedDepartmentId = GetLinkedDepartmentForCaller(link); + if (link == null || !link.LinkEnabled || !linkedDepartmentId.HasValue) + throw new HubException("Not authorized for this department link."); - if (link != null && link.LinkEnabled) - await Groups.AddToGroupAsync(Context.ConnectionId, link.DepartmentId.ToString()); + await Groups.AddToGroupAsync(Context.ConnectionId, linkedDepartmentId.Value.ToString()); } public async Task UnsubscribeToDepartmentLink(int linkId) { var link = await _departmentLinksService.GetLinkByIdAsync(linkId); - - if (link != null) - await Groups.RemoveFromGroupAsync(Context.ConnectionId, link.DepartmentId.ToString()); - } - - public async Task PersonnelStatusUpdated(int departmentId, int id) - { - var group = Clients.Group(departmentId.ToString()); - - if (group != null) - await group.SendAsync("PersonnelStatusUpdated", id); - } - - public async Task PersonnelStaffingUpdated(int departmentId, int id) - { - var group = Clients.Group(departmentId.ToString()); - - if (group != null) - await group.SendAsync("PersonnelStaffingUpdated", id); - } - - public async Task UnitStatusUpdated(int departmentId, int id) - { - var group = Clients.Group(departmentId.ToString()); - - if (group != null) - await group.SendAsync("UnitStatusUpdated", id); - } - - public async Task CallsUpdated(int departmentId, int id) - { - var group = Clients.Group(departmentId.ToString()); - - if (group != null) - await group.SendAsync("CallsUpdated", id); - } - - public async Task DepartmentUpdated(int departmentId) - { - var group = Clients.Group(departmentId.ToString()); - - if (group != null) - await group.SendAsync("DepartmentUpdated"); + var linkedDepartmentId = GetLinkedDepartmentForCaller(link); + if (linkedDepartmentId.HasValue) + await Groups.RemoveFromGroupAsync(Context.ConnectionId, linkedDepartmentId.Value.ToString()); } public async Task SubscribeToCall(int callId) { + var call = await _callsService.GetCallByIdAsync(callId); + if (call == null || call.DepartmentId != GetDepartmentId()) + throw new HubException("Not authorized for this call."); + await Groups.AddToGroupAsync(Context.ConnectionId, $"CallUpdated:${callId}"); } - public async Task UnsubscribeToCall(int callId) - { - await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"CallUpdated:${callId}"); - } + public Task UnsubscribeToCall(int callId) => + Groups.RemoveFromGroupAsync(Context.ConnectionId, $"CallUpdated:${callId}"); - public async Task CallDataUpdated(int callId) - { - var group = Clients.Group($"CallUpdated:${callId}"); + public Task PersonnelStatusUpdated(int departmentId, int id) => + PublishAsync("PersonnelStatusUpdated", departmentId, id); - if (group != null) - await group.SendAsync("CallDataUpdated", callId); - } + public Task PersonnelStaffingUpdated(int departmentId, int id) => + PublishAsync("PersonnelStaffingUpdated", departmentId, id); - public async Task CallAdded(int departmentId, int id) - { - var group = Clients.Group(departmentId.ToString()); + public Task UnitStatusUpdated(int departmentId, int id) => + PublishAsync("UnitStatusUpdated", departmentId, id); - if (group != null) - await group.SendAsync("CallAdded", id); - } + public Task CallsUpdated(int departmentId, int id) => + PublishAsync("CallsUpdated", departmentId, id); - public async Task CallClosed(int departmentId, int id) + private Task PublishAsync(string method, int departmentId, int id) { - var group = Clients.Group(departmentId.ToString()); - - if (group != null) - await group.SendAsync("CallClosed", id); - } - - public async Task WeatherAlertReceived(int departmentId, string alertId) - { - var group = Clients.Group(departmentId.ToString()); - - if (group != null) - await group.SendAsync("WeatherAlertReceived", alertId); + DemandInternalPublisher(); + return Clients.Group(departmentId.ToString()).SendAsync(method, id); } - public async Task WeatherAlertExpired(int departmentId, string alertId) + private void DemandInternalPublisher() { - var group = Clients.Group(departmentId.ToString()); - - if (group != null) - await group.SendAsync("WeatherAlertExpired", alertId); + var subject = Context.User?.FindFirst("sub")?.Value ?? + Context.User?.FindFirst(ClaimTypes.PrimarySid)?.Value; + if (!string.Equals(subject, "system_eventing", System.StringComparison.Ordinal)) + throw new HubException("This operation is reserved for the eventing publisher."); } - public async Task WeatherAlertUpdated(int departmentId, string alertId) + private int? GetLinkedDepartmentForCaller(Resgrid.Model.DepartmentLink link) { - var group = Clients.Group(departmentId.ToString()); + if (link == null) + return null; - if (group != null) - await group.SendAsync("WeatherAlertUpdated", alertId); + var departmentId = GetDepartmentId(); + if (link.DepartmentId == departmentId) + return link.LinkedDepartmentId; + if (link.LinkedDepartmentId == departmentId) + return link.DepartmentId; + return null; } } } diff --git a/Web/Resgrid.Web.Eventing/Middleware/SessionValidationHubFilter.cs b/Web/Resgrid.Web.Eventing/Middleware/SessionValidationHubFilter.cs new file mode 100644 index 000000000..471e3931b --- /dev/null +++ b/Web/Resgrid.Web.Eventing/Middleware/SessionValidationHubFilter.cs @@ -0,0 +1,116 @@ +using System; +using System.Globalization; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using OpenIddict.Abstractions; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Eventing.Middleware +{ + /// + /// Revalidates authenticated user state for every invocation on an already-open + /// SignalR connection. This prevents a revoked session from continuing to publish + /// chat, location, or subscription commands until its access token naturally expires. + /// + public class SessionValidationHubFilter : IHubFilter + { + private readonly IUserSessionService _userSessionService; + + public SessionValidationHubFilter(IUserSessionService userSessionService) + { + _userSessionService = userSessionService; + } + + public async ValueTask InvokeMethodAsync(HubInvocationContext invocationContext, + Func> next) + { + if (!await IsValidAsync(invocationContext.Context)) + { + invocationContext.Context.Abort(); + throw new HubException("This authentication session is no longer valid."); + } + + return await next(invocationContext); + } + + public Task OnConnectedAsync(HubLifetimeContext context, Func next) => + next(context); + + public Task OnDisconnectedAsync(HubLifetimeContext context, Exception exception, + Func next) => next(context, exception); + + private async Task IsValidAsync(HubCallerContext context) + { + var principal = context.User; + if (principal?.Identity?.IsAuthenticated != true) + return true; + + var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? + principal.FindFirstValue(ClaimTypes.PrimarySid) ?? + principal.FindFirstValue(OpenIddictConstants.Claims.Subject); + if (string.IsNullOrWhiteSpace(userId) || userId.StartsWith("dept_", StringComparison.Ordinal) || + userId.StartsWith("system_", StringComparison.Ordinal)) + return true; + + long? generation = long.TryParse( + principal.FindFirstValue(SessionClaimTypes.AuthenticationGeneration), NumberStyles.Integer, + CultureInfo.InvariantCulture, out var parsedGeneration) ? parsedGeneration : null; + int? departmentId = int.TryParse(principal.FindFirstValue(ClaimTypes.PrimaryGroupSid), + out var parsedDepartmentId) ? parsedDepartmentId : null; + + try + { + var validation = await _userSessionService.ValidateAsync(new SessionPrincipalContext + { + UserId = userId, + SessionId = principal.FindFirstValue(SessionClaimTypes.SessionId), + AuthenticationGeneration = generation, + DepartmentId = departmentId, + CredentialIssuedOn = GetIssuedOn(principal) + }, context.ConnectionAborted); + if (!validation.IsValid) + return false; + + if (validation.Session != null) + { + var httpContext = context.GetHttpContext(); + try + { + await _userSessionService.TouchAsync(validation.Session.UserSessionId, new RequestActivity + { + OccurredOn = DateTime.UtcNow, + IpAddress = httpContext?.Connection.RemoteIpAddress?.ToString(), + UserAgent = httpContext?.Request.Headers.UserAgent + }, context.ConnectionAborted); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "Eventing session activity update failed."); + } + } + + return true; + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "Eventing hub authentication state validation unavailable."); + return false; + } + } + + private static DateTime? GetIssuedOn(ClaimsPrincipal principal) + { + var value = principal.FindFirstValue(OpenIddictConstants.Claims.IssuedAt); + if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)) + { + try { return DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime; } + catch (ArgumentOutOfRangeException) { return null; } + } + + return DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, + out var parsed) ? parsed.ToUniversalTime() : null; + } + } +} diff --git a/Web/Resgrid.Web.Eventing/Middleware/SessionValidationMiddleware.cs b/Web/Resgrid.Web.Eventing/Middleware/SessionValidationMiddleware.cs new file mode 100644 index 000000000..6150cd58f --- /dev/null +++ b/Web/Resgrid.Web.Eventing/Middleware/SessionValidationMiddleware.cs @@ -0,0 +1,108 @@ +using System; +using System.Globalization; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using OpenIddict.Abstractions; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Eventing.Middleware +{ + public class SessionValidationMiddleware + { + private readonly RequestDelegate _next; + + public SessionValidationMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, IUserSessionService userSessionService) + { + var principal = context.User; + if (principal?.Identity?.IsAuthenticated != true) + { + await _next(context); + return; + } + + var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? + principal.FindFirstValue(ClaimTypes.PrimarySid) ?? + principal.FindFirstValue(OpenIddictConstants.Claims.Subject); + if (string.IsNullOrWhiteSpace(userId) || userId.StartsWith("dept_", StringComparison.Ordinal) || + userId.StartsWith("system_", StringComparison.Ordinal)) + { + await _next(context); + return; + } + + long? generation = null; + if (long.TryParse(principal.FindFirstValue(SessionClaimTypes.AuthenticationGeneration), + NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedGeneration)) + generation = parsedGeneration; + + int? departmentId = null; + if (int.TryParse(principal.FindFirstValue(ClaimTypes.PrimaryGroupSid), out var parsedDepartmentId)) + departmentId = parsedDepartmentId; + + SessionValidationResult validation; + try + { + validation = await userSessionService.ValidateAsync(new SessionPrincipalContext + { + UserId = userId, + SessionId = principal.FindFirstValue(SessionClaimTypes.SessionId), + AuthenticationGeneration = generation, + DepartmentId = departmentId, + CredentialIssuedOn = GetIssuedOn(principal) + }, context.RequestAborted); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "Eventing authentication state validation unavailable."); + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + return; + } + + if (!validation.IsValid) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + if (validation.Session != null) + { + try + { + await userSessionService.TouchAsync(validation.Session.UserSessionId, new RequestActivity + { + OccurredOn = DateTime.UtcNow, + IpAddress = context.Connection.RemoteIpAddress?.ToString(), + UserAgent = context.Request.Headers.UserAgent + }, context.RequestAborted); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "Eventing session activity update failed."); + } + } + + await _next(context); + } + + private static DateTime? GetIssuedOn(ClaimsPrincipal principal) + { + var value = principal.FindFirstValue(OpenIddictConstants.Claims.IssuedAt); + if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)) + { + try { return DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime; } + catch (ArgumentOutOfRangeException) { return null; } + } + + return DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) + ? parsed.ToUniversalTime() + : null; + } + } +} diff --git a/Web/Resgrid.Web.Eventing/Startup.cs b/Web/Resgrid.Web.Eventing/Startup.cs index 1b10839ee..4c2354472 100644 --- a/Web/Resgrid.Web.Eventing/Startup.cs +++ b/Web/Resgrid.Web.Eventing/Startup.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -53,9 +54,11 @@ public class Startup public ILifetimeScope AutofacContainer { get; private set; } public AutofacServiceLocator Locator { get; private set; } public IServiceCollection Services { get; private set; } + private readonly IHostingEnvironment _environment; public Startup(IHostingEnvironment env) { + _environment = env; var builder = new Microsoft.Extensions.Configuration.ConfigurationBuilder() .SetBasePath(env.ContentRootPath) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) @@ -69,7 +72,7 @@ public Startup(IHostingEnvironment env) // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - IdentityModelEventSource.ShowPII = true; + IdentityModelEventSource.ShowPII = _environment.IsDevelopment(); bool configResult = ConfigProcessor.LoadAndProcessConfig(Configuration["AppOptions:ConfigPath"]); bool envConfigResult = ConfigProcessor.LoadAndProcessEnvVariables(Configuration.AsEnumerable()); @@ -93,6 +96,9 @@ public void ConfigureServices(IServiceCollection services) Framework.Logging.Initialize(ExternalErrorConfig.ExternalErrorServiceUrlForEventing); + if (Config.ApiConfig.BypassSslChecks && !(_environment.IsDevelopment() || _environment.IsStaging())) + throw new InvalidOperationException("ApiConfig.BypassSslChecks cannot be enabled outside development or staging."); + if (Config.ApiConfig.BypassSslChecks) { services.AddHttpClient("ByPassSSLHttpClient") @@ -126,6 +132,7 @@ public void ConfigureServices(IServiceCollection services) hubOptions.EnableDetailedErrors = true; hubOptions.KeepAliveInterval = TimeSpan.FromSeconds(10); hubOptions.HandshakeTimeout = TimeSpan.FromSeconds(5); + hubOptions.AddFilter(); }).AddStackExchangeRedis(CacheConfig.RedisConnectionString, options => { @@ -360,11 +367,7 @@ public void ConfigureContainer(ContainerBuilder builder) // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { - var forwardOpts = new ForwardedHeadersOptions - { - ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedFor - }; - app.UseForwardedHeaders(forwardOpts); + app.UseForwardedHeaders(); this.AutofacContainer = app.ApplicationServices.GetAutofacRoot(); var eventAggregator = this.AutofacContainer.Resolve(); @@ -390,6 +393,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseAuthentication(); app.UseRouting(); + app.UseMiddleware(); app.UseAuthorization(); @@ -402,8 +406,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { endpoints.MapControllers(); - endpoints.MapHub("/eventingHub"); - endpoints.MapHub("/geolocationHub"); + endpoints.MapHub("/eventingHub", options => options.CloseOnAuthenticationExpiration = true); + endpoints.MapHub("/geolocationHub", options => options.CloseOnAuthenticationExpiration = true); endpoints.MapHub("/chatHub", options => options.CloseOnAuthenticationExpiration = true); }); } diff --git a/Web/Resgrid.Web.Mcp/Startup.cs b/Web/Resgrid.Web.Mcp/Startup.cs index 5e5525d21..29f1d6cf5 100644 --- a/Web/Resgrid.Web.Mcp/Startup.cs +++ b/Web/Resgrid.Web.Mcp/Startup.cs @@ -107,6 +107,9 @@ public void ConfigureServices(IServiceCollection services) { client.BaseAddress = new Uri(SystemBehaviorConfig.ResgridApiBaseUrl); client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.DefaultRequestHeaders.Add("X-Resgrid-Client", "mcp"); + client.DefaultRequestHeaders.Add("X-Resgrid-Device-Name", "Resgrid MCP gateway"); + client.DefaultRequestHeaders.Add("X-Resgrid-App-Version", McpConfig.ServerVersion); client.Timeout = TimeSpan.FromSeconds(30); }) .ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs index cd2dfa210..1634a2245 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs @@ -11,6 +11,7 @@ using Resgrid.Model; using Resgrid.Model.Providers; using Resgrid.Model.Services; +using Resgrid.Model.Security; using Resgrid.Web.Services.Helpers; using Resgrid.Web.Services.Models.v4.Sso; using System; @@ -50,6 +51,8 @@ public class ConnectController : ControllerBase private readonly IDepartmentSsoService _departmentSsoService; private readonly IEncryptionService _encryptionService; private readonly ICacheProvider _cacheProvider; + private readonly IUserSessionService _userSessionService; + private readonly IExternalIdentityLinkService _externalIdentityLinkService; public ConnectController( IUsersService usersService, @@ -60,7 +63,9 @@ public ConnectController( ISystemAuditsService systemAuditsService, IDepartmentSsoService departmentSsoService, IEncryptionService encryptionService, - ICacheProvider cacheProvider + ICacheProvider cacheProvider, + IUserSessionService userSessionService, + IExternalIdentityLinkService externalIdentityLinkService ) { _usersService = usersService; @@ -72,6 +77,8 @@ ICacheProvider cacheProvider _departmentSsoService = departmentSsoService; _encryptionService = encryptionService; _cacheProvider = cacheProvider; + _userSessionService = userSessionService; + _externalIdentityLinkService = externalIdentityLinkService; } /// @@ -113,6 +120,48 @@ public async Task Token() return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } + var userDepartment = await _departmentsService.GetDepartmentByUserIdAsync(user.Id); + if (userDepartment == null) + { + audit.UserId = user.Id; + await _systemAuditsService.SaveSystemAuditAsync(audit); + return InvalidGrant("The username or password is invalid."); + } + var activeMembership = await _departmentsService.GetDepartmentMemberAsync(user.Id, + userDepartment.DepartmentId, bypassCache: true); + if (activeMembership == null || activeMembership.IsDeleted || activeMembership.IsDisabled == true) + { + audit.UserId = user.Id; + await _systemAuditsService.SaveSystemAuditAsync(audit); + return InvalidGrant("The username or password is invalid."); + } + + var localLoginAllowed = await _externalIdentityLinkService.IsLocalLoginAllowedAsync( + user.Id, CancellationToken.None); + if (localLoginAllowed && userDepartment != null) + { + localLoginAllowed = await _externalIdentityLinkService.IsLocalLoginAllowedAsync( + user.Id, userDepartment.DepartmentId, CancellationToken.None); + var requiresSso = await _departmentSsoService.IsRequireSsoPolicyActiveAsync( + userDepartment.DepartmentId, CancellationToken.None); + if (requiresSso && await _departmentSsoService.IsSsoEnabledForDepartmentAsync( + userDepartment.DepartmentId, CancellationToken.None)) + localLoginAllowed = false; + } + + if (!localLoginAllowed) + { + audit.UserId = user.Id; + await _systemAuditsService.SaveSystemAuditAsync(audit); + var properties = new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The username or password is invalid." + }); + return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + // Validate the username/password parameters and ensure the account is not locked out. var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true); @@ -132,26 +181,6 @@ public async Task Token() return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } - // SSO-only guard — only enforced when the user's department has explicitly - // configured RequireSso=true AND has at least one active SSO configuration. - // Departments that have NOT configured SSO are completely unaffected. - var userDepartment = await _departmentsService.GetDepartmentByUserIdAsync(user.Id); - if (userDepartment != null) - { - var requiresSso = await _departmentSsoService.IsRequireSsoPolicyActiveAsync(userDepartment.DepartmentId, CancellationToken.None); - var hasSso = requiresSso && await _departmentSsoService.IsSsoEnabledForDepartmentAsync(userDepartment.DepartmentId, CancellationToken.None); - if (requiresSso && hasSso) - { - var ssoProps = new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = - "This department requires SSO login. Password-based login is disabled." - }); - return Forbid(ssoProps, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - } - } - // Create a new ClaimsPrincipal containing the claims that // will be used to create an id_token, a token or a code. var principal = await _signInManager.CreateUserPrincipalAsync(user); @@ -168,22 +197,31 @@ public async Task Token() Scopes.Roles }.Intersect(request.GetScopes())); - foreach (var claim in principal.Claims) + var refreshTokenLifetime = GetRefreshTokenLifetime(request); + if (SessionSecurityConfig.TrackingEnabled) { - claim.SetDestinations(GetDestinations(claim, principal)); + try + { + var session = await CreateApiSessionAsync(user, userDepartment?.DepartmentId, + UserSessionAuthenticationMethod.LocalPassword, refreshTokenLifetime, CancellationToken.None); + AddSessionClaims(principal, session); + } + catch (SessionCreationDeniedException ex) + { + return InvalidGrant(ex.FailureCode == "maximum_sessions" + ? "The department's maximum number of active sessions has been reached." + : "The user is no longer allowed to sign in to this department."); + } } - if (request.GetScopes() != null && request.GetScopes().Contains("mobile")) - { - principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(OidcConfig.AccessTokenExpiryMinutes)); - principal.SetRefreshTokenLifetime(TimeSpan.FromDays(OidcConfig.RefreshTokenExpiryDays)); - } - else + foreach (var claim in principal.Claims) { - principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(OidcConfig.AccessTokenExpiryMinutes)); - principal.SetRefreshTokenLifetime(TimeSpan.FromDays(OidcConfig.NonMobileRefreshTokenExpiryDays)); + claim.SetDestinations(GetDestinations(claim, principal)); } + principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(OidcConfig.AccessTokenExpiryMinutes)); + principal.SetRefreshTokenLifetime(refreshTokenLifetime); + principal.SetResources(JwtConfig.EventsClientId); return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); @@ -193,12 +231,7 @@ public async Task Token() { // Retrieve the claims principal stored in the refresh token. var info = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - - // Retrieve the user profile corresponding to the refresh token. - // Note: if you want to automatically invalidate the refresh token - // when the user password/roles change, use the following line instead: - // var user = _signInManager.ValidateSecurityStampAsync(info.Principal); - var user = await _userManager.GetUserAsync(info.Principal); + var user = info.Principal == null ? null : await _signInManager.ValidateSecurityStampAsync(info.Principal); if (user == null) { var properties = new AuthenticationProperties(new Dictionary @@ -210,6 +243,33 @@ public async Task Token() return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } + int? departmentId = null; + if (int.TryParse(info.Principal.FindFirstValue(ClaimTypes.PrimaryGroupSid), out var parsedDepartmentId)) + departmentId = parsedDepartmentId; + + long? authenticationGeneration = null; + if (long.TryParse(info.Principal.FindFirstValue(SessionClaimTypes.AuthenticationGeneration), out var parsedGeneration)) + authenticationGeneration = parsedGeneration; + + var validation = await _userSessionService.ValidateAsync(new SessionPrincipalContext + { + UserId = user.Id, + SessionId = info.Principal.FindFirstValue(SessionClaimTypes.SessionId), + AuthenticationGeneration = authenticationGeneration, + DepartmentId = departmentId, + CredentialIssuedOn = GetCredentialIssuedOn(info.Principal) + }, CancellationToken.None); + + if (!validation.IsValid) + { + var properties = new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The refresh token is no longer valid." + }); + return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + // Ensure the user is still allowed to sign in. if (!await _signInManager.CanSignInAsync(user)) { @@ -225,12 +285,104 @@ public async Task Token() // Create a new ClaimsPrincipal containing the claims that // will be used to create an id_token, a token or a code. var principal = await _signInManager.CreateUserPrincipalAsync(user); + principal.SetScopes(info.Principal.GetScopes()); + + var refreshTokenLifetime = GetRefreshTokenLifetime(request); + var session = validation.Session; + if (session == null && SessionSecurityConfig.TrackingEnabled) + { + try + { + session = await _userSessionService.AdoptLegacyAsync(new LegacySessionContext + { + UserId = user.Id, + DepartmentId = departmentId, + AuthenticationGeneration = user.AuthenticationGeneration, + ClientApplication = ResolveClientApplication(Request.Headers["X-Resgrid-Client"]), + DeviceName = Request.Headers["X-Resgrid-Device-Name"], + DeviceType = Request.Headers["X-Resgrid-Device-Type"], + OperatingSystem = Request.Headers["X-Resgrid-Operating-System"], + Browser = Request.Headers["X-Resgrid-Browser"], + ApplicationVersion = Request.Headers["X-Resgrid-App-Version"], + ExpiresOn = DateTime.UtcNow.Add(refreshTokenLifetime), + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + UserAgent = Request.Headers["User-Agent"] + }, CancellationToken.None); + } + catch (SessionCreationDeniedException ex) + { + return InvalidGrant(ex.FailureCode == "maximum_sessions" + ? "The department's maximum number of active sessions has been reached." + : "The user is no longer allowed to sign in to this department."); + } + } + + if (session != null) + { + AddSessionClaims(principal, session); + await _userSessionService.TouchAsync(session.UserSessionId, new RequestActivity + { + OccurredOn = DateTime.UtcNow, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + UserAgent = Request.Headers["User-Agent"] + }, CancellationToken.None); + } foreach (var claim in principal.Claims) { claim.SetDestinations(GetDestinations(claim, principal)); } + principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(OidcConfig.AccessTokenExpiryMinutes)); + principal.SetRefreshTokenLifetime(refreshTokenLifetime); + principal.SetResources(JwtConfig.EventsClientId); + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + else if (request != null && string.Equals(request.GrantType, "web_session", StringComparison.Ordinal)) + { + var suppliedKey = Request.Headers["X-Resgrid-Internal-Key"].ToString(); + var userId = request.GetParameter("user_id").ToString(); + var sessionId = request.GetParameter("session_id").ToString(); + var generationValue = request.GetParameter("auth_ver").ToString(); + var departmentValue = request.GetParameter("department_id").ToString(); + var eventingOnly = string.Equals(request.GetParameter("token_use").ToString(), + "eventing", StringComparison.Ordinal); + + if (string.IsNullOrWhiteSpace(ApiConfig.BackendInternalApikey) || + !FixedTimeSecretEquals(ApiConfig.BackendInternalApikey, suppliedKey) || + string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(sessionId) || + !long.TryParse(generationValue, out var generation) || + !int.TryParse(departmentValue, out var departmentId)) + return InvalidGrant("The Web session could not be validated."); + + var validation = await _userSessionService.ValidateAsync(new SessionPrincipalContext + { + UserId = userId, + SessionId = sessionId, + AuthenticationGeneration = generation, + DepartmentId = departmentId, + CredentialIssuedOn = DateTime.UtcNow + }, CancellationToken.None); + if (!validation.IsValid || validation.Session == null) + return InvalidGrant("The Web session could not be validated."); + + var user = await _userManager.FindByIdAsync(userId); + if (user == null || !await _signInManager.CanSignInAsync(user)) + return InvalidGrant("The Web session could not be validated."); + + var principal = await _signInManager.CreateUserPrincipalAsync(user); + principal.SetScopes(Scopes.OpenId, Scopes.Email, Scopes.Profile, Scopes.Roles); + AddSessionClaims(principal, validation.Session); + if (eventingOnly && principal.Identity is ClaimsIdentity eventingIdentity) + eventingIdentity.AddClaim(new Claim(SessionClaimTypes.WebEventingOnly, "true")); + foreach (var claim in principal.Claims) + claim.SetDestinations(GetDestinations(claim, principal)); + principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(eventingOnly ? 2 : + Math.Max(1, SessionSecurityConfig.WebBffAccessTokenLifetimeMinutes))); + principal.SetResources(JwtConfig.EventsClientId); + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } @@ -248,6 +400,27 @@ public async Task Token() audit.ServerName = Environment.MachineName; audit.Data = $"V4 Token (client_credentials), {Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + // Dedicated server-to-server credential for the legacy direct eventing publisher. + // It can publish to SignalR but is explicitly excluded from user-session handling. + if (string.Equals(request.ClientId, "resgrid_eventing", StringComparison.Ordinal) && + !string.IsNullOrWhiteSpace(ApiConfig.BackendInternalApikey) && + FixedTimeSecretEquals(ApiConfig.BackendInternalApikey, request.ClientSecret)) + { + var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + Claims.Name, Claims.Role); + identity.AddClaim(new Claim(Claims.Subject, "system_eventing") + .SetDestinations(Destinations.AccessToken)); + identity.AddClaim(new Claim(ClaimTypes.PrimarySid, "system_eventing") + .SetDestinations(Destinations.AccessToken)); + identity.AddClaim(new Claim(Claims.Name, "Resgrid Eventing Publisher") + .SetDestinations(Destinations.AccessToken)); + var principal = new ClaimsPrincipal(identity); + principal.SetScopes(Scopes.OpenId, Scopes.Profile); + principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(5)); + principal.SetResources(JwtConfig.EventsClientId); + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + if (string.IsNullOrWhiteSpace(request.ClientId) || string.IsNullOrWhiteSpace(request.ClientSecret)) { await _systemAuditsService.SaveSystemAuditAsync(audit); @@ -387,6 +560,16 @@ public async Task Token() throw new NotImplementedException("The specified grant type is not implemented."); } + private IActionResult InvalidGrant(string description) + { + var properties = new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description + }); + return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + /// /// Returns the SSO configuration for a department so the mobile app can determine /// whether to show the SSO login button, which flow to use (OIDC/SAML), and which @@ -746,17 +929,33 @@ public async Task ExternalToken( Scopes.Roles }); + var refreshTokenLifetime = GetRefreshTokenLifetime(null); + if (SessionSecurityConfig.TrackingEnabled) + { + try + { + var session = await CreateApiSessionAsync(user, department.DepartmentId, + providerType == SsoProviderType.Oidc ? UserSessionAuthenticationMethod.OidcSso : UserSessionAuthenticationMethod.SamlSso, + refreshTokenLifetime, cancellationToken, ssoConfig?.DepartmentSsoConfigId); + AddSessionClaims(principal, session); + } + catch (SessionCreationDeniedException ex) + { + return Unauthorized(new + { + error = ex.FailureCode, + error_description = ex.FailureCode == "maximum_sessions" + ? "The department's maximum number of active sessions has been reached." + : "The user is no longer allowed to sign in to this department." + }); + } + } + foreach (var claim in principal.Claims) claim.SetDestinations(GetDestinations(claim, principal)); - // Mirror the mobile-scope lifetime logic from the password-grant Token endpoint - var isMobile = !string.IsNullOrWhiteSpace(scope) && - scope.Split(' ').Any(s => string.Equals(s, "mobile", StringComparison.OrdinalIgnoreCase)); - principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(OidcConfig.AccessTokenExpiryMinutes)); - principal.SetRefreshTokenLifetime(isMobile - ? TimeSpan.FromDays(OidcConfig.RefreshTokenExpiryDays) - : TimeSpan.FromDays(OidcConfig.NonMobileRefreshTokenExpiryDays)); + principal.SetRefreshTokenLifetime(refreshTokenLifetime); principal.SetResources(JwtConfig.EventsClientId); @@ -881,6 +1080,89 @@ private async Task ConsumeSamlRelayAsync(string relayToken) private static string GetSamlRelayUseCacheKey(string relayId) => $"Sso:SamlRelayUse:{relayId}"; + private TimeSpan GetRefreshTokenLifetime(OpenIddictRequest request) + { + var clientId = request?.ClientId; + var isTrustedLongLivedClient = !string.IsNullOrWhiteSpace(clientId) && + (OidcConfig.TrustedLongLivedClientIds ?? string.Empty) + .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(value => value.Trim()) + .Any(value => string.Equals(value, clientId, StringComparison.Ordinal)); + + return TimeSpan.FromDays(isTrustedLongLivedClient + ? OidcConfig.RefreshTokenExpiryDays + : OidcConfig.NonMobileRefreshTokenExpiryDays); + } + + private static DateTime? GetCredentialIssuedOn(ClaimsPrincipal principal) + { + var value = principal?.FindFirstValue(Claims.IssuedAt); + if (long.TryParse(value, out var unixSeconds)) + { + try { return DateTimeOffset.FromUnixTimeSeconds(unixSeconds).UtcDateTime; } + catch (ArgumentOutOfRangeException) { return null; } + } + + return DateTime.TryParse(value, out var timestamp) ? timestamp.ToUniversalTime() : null; + } + + private async Task CreateApiSessionAsync(Model.Identity.IdentityUser user, int? departmentId, + UserSessionAuthenticationMethod authenticationMethod, TimeSpan refreshTokenLifetime, + CancellationToken cancellationToken, string departmentSsoConfigId = null) + { + return await _userSessionService.CreateSessionAsync(new SessionIssueContext + { + UserId = user.Id, + DepartmentId = departmentId, + AuthenticationGeneration = user.AuthenticationGeneration, + ClientApplication = ResolveClientApplication(Request.Headers["X-Resgrid-Client"]), + DeviceName = Request.Headers["X-Resgrid-Device-Name"], + DeviceType = Request.Headers["X-Resgrid-Device-Type"], + OperatingSystem = Request.Headers["X-Resgrid-Operating-System"], + Browser = Request.Headers["X-Resgrid-Browser"], + ApplicationVersion = Request.Headers["X-Resgrid-App-Version"], + AuthenticationMethod = authenticationMethod, + DepartmentSsoConfigId = departmentSsoConfigId, + ExpiresOn = DateTime.UtcNow.Add(refreshTokenLifetime), + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + UserAgent = Request.Headers["User-Agent"] + }, cancellationToken); + } + + private static UserSessionClientApplication ResolveClientApplication(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return UserSessionClientApplication.Api; + + return value.Trim().ToLowerInvariant() switch + { + "web" => UserSessionClientApplication.Web, + "responder" => UserSessionClientApplication.Responder, + "unit" => UserSessionClientApplication.Unit, + "dispatch" => UserSessionClientApplication.Dispatch, + "bigboard" => UserSessionClientApplication.BigBoard, + "command" => UserSessionClientApplication.Command, + "ic" => UserSessionClientApplication.Command, + "mcp" => UserSessionClientApplication.Mcp, + _ => UserSessionClientApplication.Api + }; + } + + private static void AddSessionClaims(ClaimsPrincipal principal, UserSession session) + { + if (principal?.Identity is not ClaimsIdentity identity || session == null) + return; + + foreach (var existing in identity.FindAll(SessionClaimTypes.SessionId).ToList()) + identity.RemoveClaim(existing); + foreach (var existing in identity.FindAll(SessionClaimTypes.AuthenticationGeneration).ToList()) + identity.RemoveClaim(existing); + + identity.AddClaim(new Claim(SessionClaimTypes.SessionId, session.UserSessionId)); + identity.AddClaim(new Claim(SessionClaimTypes.AuthenticationGeneration, + session.AuthenticationGeneration.ToString(System.Globalization.CultureInfo.InvariantCulture))); + } + private IEnumerable GetDestinations(Claim claim, ClaimsPrincipal principal) { // Note: by default, claims are NOT automatically included in the access and identity tokens. // To allow OpenIddict to serialize them, you must attach them a destination, that specifies diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ScimController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ScimController.cs index 1a098e28a..5ba20d1f4 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ScimController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ScimController.cs @@ -33,19 +33,25 @@ public class ScimController : ControllerBase private readonly IUserProfileService _userProfileService; private readonly ISystemAuditsService _systemAuditsService; private readonly UserManager _userManager; + private readonly IExternalIdentityLinkService _externalIdentityLinkService; + private readonly IUserSessionService _userSessionService; public ScimController( IDepartmentSsoService ssoService, IDepartmentsService departmentsService, IUserProfileService userProfileService, ISystemAuditsService systemAuditsService, - UserManager userManager) + UserManager userManager, + IExternalIdentityLinkService externalIdentityLinkService, + IUserSessionService userSessionService) { _ssoService = ssoService; _departmentsService = departmentsService; _userProfileService = userProfileService; _systemAuditsService = systemAuditsService; _userManager = userManager; + _externalIdentityLinkService = externalIdentityLinkService; + _userSessionService = userSessionService; } // -- GET /scim/v2/Users ------------------------------------------------ @@ -179,6 +185,27 @@ await SaveScimAuditAsync(departmentId, null, AuditLogTypes.ScimUserCreated, }; await _userProfileService.SaveProfileAsync(departmentId, profile, cancellationToken); + var member = await _departmentsService.GetDepartmentMemberAsync(newUser.Id, departmentId); + var ssoConfig = (await _ssoService.GetSsoConfigsForDepartmentAsync(departmentId, cancellationToken)) + ?.FirstOrDefault(config => config.ScimEnabled); + if (member != null && ssoConfig != null) + { + await _externalIdentityLinkService.SaveAsync(new UserExternalIdentityLink + { + UserId = newUser.Id, + DepartmentId = departmentId, + DepartmentMemberId = member.DepartmentMemberId, + DepartmentSsoConfigId = ssoConfig.DepartmentSsoConfigId, + ProviderType = ssoConfig.SsoProviderType, + Issuer = $"scim:{ssoConfig.DepartmentSsoConfigId}", + ExternalSubject = resource.ExternalId ?? newUser.Id, + EmailAtLink = email, + LinkMethod = (int)ExternalIdentityLinkMethod.Scim, + IsEmailExternallyManaged = true, + LinkedOn = DateTime.UtcNow + }, cancellationToken); + } + await SaveScimAuditAsync(departmentId, newUser.Id, AuditLogTypes.ScimUserCreated, successful: true, data: $"userName={resource.UserName} email={email} externalId={resource.ExternalId}"); @@ -218,6 +245,8 @@ await SaveScimAuditAsync(departmentId, id, AuditLogTypes.ScimUserUpdated, { member.IsDisabled = true; await _departmentsService.SaveDepartmentMemberAsync(member, cancellationToken); + await _userSessionService.RevokeDepartmentSessionsAsync(id, departmentId, + UserSessionRevocationReason.MembershipDisabled, cancellationToken); await SaveScimAuditAsync(departmentId, id, AuditLogTypes.ScimUserDeactivated, successful: true, data: "active=false via PUT"); } @@ -284,6 +313,9 @@ await SaveScimAuditAsync(departmentId, id, AuditLogTypes.ScimUserUpdated, member.IsDisabled = !active; if (!active) member.IsDeleted = false; // deactivate without hard-delete await _departmentsService.SaveDepartmentMemberAsync(member, cancellationToken); + if (!active) + await _userSessionService.RevokeDepartmentSessionsAsync(id, departmentId, + UserSessionRevocationReason.MembershipDisabled, cancellationToken); var activeAuditType = active ? AuditLogTypes.ScimUserReactivated : AuditLogTypes.ScimUserDeactivated; await SaveScimAuditAsync(departmentId, id, activeAuditType, @@ -331,6 +363,8 @@ await SaveScimAuditAsync(departmentId, id, AuditLogTypes.ScimUserDeleted, member.IsDisabled = true; member.IsDeleted = true; await _departmentsService.SaveDepartmentMemberAsync(member, cancellationToken); + await _userSessionService.RevokeDepartmentSessionsAsync(id, departmentId, + UserSessionRevocationReason.MembershipDisabled, cancellationToken); } await SaveScimAuditAsync(departmentId, id, AuditLogTypes.ScimUserDeactivated, diff --git a/Web/Resgrid.Web.Services/Controllers/v4/SessionsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/SessionsController.cs new file mode 100644 index 000000000..cc9ea49c6 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/SessionsController.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// User authentication session inventory and revocation. + [Route("api/v{VersionId:apiVersion}/sessions")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class SessionsController : V4AuthenticatedApiControllerbase + { + private readonly IUserSessionService _userSessionService; + private readonly ISystemAuditsService _systemAuditsService; + + public SessionsController(IUserSessionService userSessionService, ISystemAuditsService systemAuditsService) + { + _userSessionService = userSessionService; + _systemAuditsService = systemAuditsService; + } + + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> Get(CancellationToken cancellationToken) + { + var currentSessionId = User.FindFirstValue(SessionClaimTypes.SessionId); + var sessions = await _userSessionService.GetActiveForUserAsync(UserId, cancellationToken); + foreach (var session in sessions) + session.IsCurrent = string.Equals(session.UserSessionId, currentSessionId, StringComparison.Ordinal); + return Ok(sessions); + } + + [HttpDelete("{sessionId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Revoke(string sessionId, CancellationToken cancellationToken) + { + var result = await _userSessionService.RevokeSessionAsync(UserId, UserId, sessionId, + UserSessionRevocationReason.UserRevoked, cancellationToken); + await AuditAsync(SystemAuditTypes.SessionRevoked, sessionId, result.RevokedSessionCount > 0, cancellationToken); + return Ok(new { revoked = result.RevokedSessionCount }); + } + + [HttpPost("revoke-others")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task RevokeOthers(CancellationToken cancellationToken) + { + var currentSessionId = User.FindFirstValue(SessionClaimTypes.SessionId); + if (string.IsNullOrWhiteSpace(currentSessionId)) + return Conflict(new { error = "legacy_session", message = "Refresh this session before revoking all others." }); + + var result = await _userSessionService.RevokeOtherSessionsAsync(UserId, currentSessionId, + UserSessionRevocationReason.OtherSessionsRevoked, cancellationToken); + await AuditAsync(SystemAuditTypes.OtherSessionsRevoked, null, true, cancellationToken); + return Ok(new { revoked = result.RevokedSessionCount }); + } + + [HttpPost("revoke-all")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task RevokeAll(CancellationToken cancellationToken) + { + var result = await _userSessionService.RevokeAllAsync(UserId, UserId, + UserSessionRevocationReason.AccountCompromised, DateTime.UtcNow, cancellationToken); + await AuditAsync(SystemAuditTypes.AllSessionsRevoked, null, true, cancellationToken); + return Ok(new { revoked = result.RevokedSessionCount, reauthenticationRequired = true }); + } + + private Task AuditAsync(SystemAuditTypes type, string sessionId, bool successful, CancellationToken cancellationToken) + { + return _systemAuditsService.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Api, + Type = (int)type, + UserId = UserId, + TargetUserId = UserId, + SessionId = SessionSupportSuffix(sessionId), + Successful = successful, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + CorrelationId = HttpContext.TraceIdentifier, + Data = $"API session operation. Agent={BoundAuditValue(Request.Headers.UserAgent.ToString(), 256)}", + LoggedOn = DateTime.UtcNow + }, cancellationToken); + } + + private static string BoundAuditValue(string value, int maximumLength) + { + if (string.IsNullOrWhiteSpace(value)) return "Unknown"; + var sanitized = value.Replace("\r", " ").Replace("\n", " ").Trim(); + return sanitized.Length <= maximumLength ? sanitized : sanitized.Substring(0, maximumLength); + } + + private static string SessionSupportSuffix(string sessionId) => + string.IsNullOrWhiteSpace(sessionId) || sessionId.Length <= 8 + ? sessionId + : sessionId.Substring(sessionId.Length - 8); + } +} diff --git a/Web/Resgrid.Web.Services/Helpers/IpAddressHelper.cs b/Web/Resgrid.Web.Services/Helpers/IpAddressHelper.cs index 4fac2534b..757739c32 100644 --- a/Web/Resgrid.Web.Services/Helpers/IpAddressHelper.cs +++ b/Web/Resgrid.Web.Services/Helpers/IpAddressHelper.cs @@ -10,7 +10,7 @@ public static class IpAddressHelper { public static string GetRequestIP(HttpRequest request, bool tryUseXForwardHeader = true) { - string ip = null; + string ip = request.HttpContext?.Connection?.RemoteIpAddress?.ToString(); // todo support new "Forwarded" header (2014) https://en.wikipedia.org/wiki/X-Forwarded-For @@ -19,12 +19,7 @@ public static string GetRequestIP(HttpRequest request, bool tryUseXForwardHeader // approach might be to read each IP from right to left and use the first public IP. // http://stackoverflow.com/a/43554000/538763 // - if (tryUseXForwardHeader) - ip = GetHeaderValueAs(request, "X-Forwarded-For").SplitCsv().FirstOrDefault(); - - // RemoteIpAddress is always null in DNX RC1 Update1 (bug). - if (ip.IsNullOrWhitespace() && request.HttpContext?.Connection?.RemoteIpAddress != null) - ip = request.HttpContext.Connection.RemoteIpAddress.ToString(); + // ForwardedHeadersMiddleware already applied trusted proxy headers. if (ip.IsNullOrWhitespace()) ip = GetHeaderValueAs(request, "REMOTE_ADDR"); diff --git a/Web/Resgrid.Web.Services/Hubs/EventingHub.cs b/Web/Resgrid.Web.Services/Hubs/EventingHub.cs index ffe1e9d53..683773629 100644 --- a/Web/Resgrid.Web.Services/Hubs/EventingHub.cs +++ b/Web/Resgrid.Web.Services/Hubs/EventingHub.cs @@ -1,8 +1,9 @@ -using System.Security.Claims; +using System; +using System.Security.Claims; using System.Threading.Tasks; -using CommonServiceLocator; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; +using OpenIddict.Validation.AspNetCore; using Resgrid.Model.Services; namespace Resgrid.Web.Services.Hubs @@ -10,105 +11,90 @@ namespace Resgrid.Web.Services.Hubs public interface IEventingHub { Task Connect(int departmentId); - Task SubscribeToDepartmentLink(int linkId); - Task UnsubscribeToDepartmentLink(int linkId); - - Task PersonnelStatusUpdated(int departmentId, int id); - - Task PersonnelStaffingUpdated(int departmentId, int id); - - Task UnitStatusUpdated(int departmentId, int id); - - Task CallsUpdated(int departmentId, int id); - - Task DepartmentUpdated(int departmentId); - - // CheckInPerformed and CheckInTimersUpdated are server-only broadcasts - // invoked via IHubContext.SendAsync() — no client-facing methods needed. } - [AllowAnonymous] + [Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] public class EventingHub : Hub { private readonly IDepartmentLinksService _departmentLinksService; - public EventingHub() + public EventingHub(IDepartmentLinksService departmentLinksService) { - _departmentLinksService = ServiceLocator.Current.GetInstance(); + _departmentLinksService = departmentLinksService; } - [Authorize] public async Task Connect(int departmentId) { - var claim = Context.User?.FindFirst(ClaimTypes.PrimaryGroupSid); - if (claim == null || !int.TryParse(claim.Value, out int userDepartmentId) || userDepartmentId != departmentId) - throw new HubException("Unauthorized: department mismatch."); + var authenticatedDepartmentId = GetDepartmentId(); + if (authenticatedDepartmentId <= 0 || authenticatedDepartmentId != departmentId) + throw new HubException("Not authorized for this department."); await Groups.AddToGroupAsync(Context.ConnectionId, departmentId.ToString()); - await Clients.Caller.SendAsync("onConnected", Context.ConnectionId); } public async Task SubscribeToDepartmentLink(int linkId) { var link = await _departmentLinksService.GetLinkByIdAsync(linkId); + var linkedDepartmentId = GetLinkedDepartmentForCaller(link); + if (link == null || !link.LinkEnabled || !linkedDepartmentId.HasValue) + throw new HubException("Not authorized for this department link."); - if (link != null && link.LinkEnabled) - await Groups.AddToGroupAsync(Context.ConnectionId, link.DepartmentId.ToString()); + await Groups.AddToGroupAsync(Context.ConnectionId, linkedDepartmentId.Value.ToString()); } public async Task UnsubscribeToDepartmentLink(int linkId) { var link = await _departmentLinksService.GetLinkByIdAsync(linkId); - - if (link != null) - await Groups.RemoveFromGroupAsync(Context.ConnectionId, link.DepartmentId.ToString()); + var linkedDepartmentId = GetLinkedDepartmentForCaller(link); + if (linkedDepartmentId.HasValue) + await Groups.RemoveFromGroupAsync(Context.ConnectionId, linkedDepartmentId.Value.ToString()); } - public async Task PersonnelStatusUpdated(int departmentId, int id) - { - var group = Clients.Group(departmentId.ToString()); + public Task PersonnelStatusUpdated(int departmentId, int id) => + PublishAsync("personnelStatusUpdated", departmentId, id); - if (group != null) - await group.SendAsync("personnelStatusUpdated", id); - } + public Task PersonnelStaffingUpdated(int departmentId, int id) => + PublishAsync("personnelStaffingUpdated", departmentId, id); - public async Task PersonnelStaffingUpdated(int departmentId, int id) - { - var group = Clients.Group(departmentId.ToString()); + public Task UnitStatusUpdated(int departmentId, int id) => + PublishAsync("unitStatusUpdated", departmentId, id); - if (group != null) - await group.SendAsync("personnelStaffingUpdated", id); - } + public Task CallsUpdated(int departmentId, int id) => + PublishAsync("callsUpdated", departmentId, id); - public async Task UnitStatusUpdated(int departmentId, int id) + private Task PublishAsync(string method, int departmentId, int id) { - var group = Clients.Group(departmentId.ToString()); - - if (group != null) - await group.SendAsync("unitStatusUpdated", id); + DemandInternalPublisher(); + return Clients.Group(departmentId.ToString()).SendAsync(method, id); } - public async Task CallsUpdated(int departmentId, int id) + private void DemandInternalPublisher() { - var group = Clients.Group(departmentId.ToString()); - - if (group != null) - await group.SendAsync("callsUpdated", id); + var subject = Context.User?.FindFirst("sub")?.Value ?? + Context.User?.FindFirst(ClaimTypes.PrimarySid)?.Value; + if (!string.Equals(subject, "system_eventing", StringComparison.Ordinal)) + throw new HubException("This operation is reserved for the eventing publisher."); } - public async Task DepartmentUpdated(int departmentId) + private int GetDepartmentId() { - var group = Clients.Group(departmentId.ToString()); - - if (group != null) - await group.SendAsync("departmentUpdated"); + var claim = Context.User?.FindFirst(ClaimTypes.PrimaryGroupSid); + return claim != null && int.TryParse(claim.Value, out var departmentId) ? departmentId : 0; } - // CheckInPerformed and CheckInTimersUpdated are server-only broadcasts. - // They are invoked via IHubContext.Clients.Group().SendAsync() - // and must not be exposed as client-callable hub methods. + private int? GetLinkedDepartmentForCaller(Resgrid.Model.DepartmentLink link) + { + if (link == null) + return null; + var departmentId = GetDepartmentId(); + if (link.DepartmentId == departmentId) + return link.LinkedDepartmentId; + if (link.LinkedDepartmentId == departmentId) + return link.DepartmentId; + return null; + } } } diff --git a/Web/Resgrid.Web.Services/Middleware/SessionValidationHubFilter.cs b/Web/Resgrid.Web.Services/Middleware/SessionValidationHubFilter.cs new file mode 100644 index 000000000..774d3a15b --- /dev/null +++ b/Web/Resgrid.Web.Services/Middleware/SessionValidationHubFilter.cs @@ -0,0 +1,112 @@ +using System; +using System.Globalization; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using OpenIddict.Abstractions; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Services.Middleware +{ + /// Revalidates user session state for every invocation on an open API SignalR connection. + public class SessionValidationHubFilter : IHubFilter + { + private readonly IUserSessionService _userSessionService; + + public SessionValidationHubFilter(IUserSessionService userSessionService) + { + _userSessionService = userSessionService; + } + + public async ValueTask InvokeMethodAsync(HubInvocationContext invocationContext, + Func> next) + { + if (!await IsValidAsync(invocationContext.Context)) + { + invocationContext.Context.Abort(); + throw new HubException("This authentication session is no longer valid."); + } + + return await next(invocationContext); + } + + public Task OnConnectedAsync(HubLifetimeContext context, Func next) => + next(context); + + public Task OnDisconnectedAsync(HubLifetimeContext context, Exception exception, + Func next) => next(context, exception); + + private async Task IsValidAsync(HubCallerContext context) + { + var principal = context.User; + if (principal?.Identity?.IsAuthenticated != true) + return true; + + var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? + principal.FindFirstValue(ClaimTypes.PrimarySid) ?? + principal.FindFirstValue(OpenIddictConstants.Claims.Subject); + if (string.IsNullOrWhiteSpace(userId) || userId.StartsWith("dept_", StringComparison.Ordinal) || + userId.StartsWith("system_", StringComparison.Ordinal)) + return true; + + long? generation = long.TryParse( + principal.FindFirstValue(SessionClaimTypes.AuthenticationGeneration), NumberStyles.Integer, + CultureInfo.InvariantCulture, out var parsedGeneration) ? parsedGeneration : null; + int? departmentId = int.TryParse(principal.FindFirstValue(ClaimTypes.PrimaryGroupSid), + out var parsedDepartmentId) ? parsedDepartmentId : null; + + try + { + var validation = await _userSessionService.ValidateAsync(new SessionPrincipalContext + { + UserId = userId, + SessionId = principal.FindFirstValue(SessionClaimTypes.SessionId), + AuthenticationGeneration = generation, + DepartmentId = departmentId, + CredentialIssuedOn = GetIssuedOn(principal) + }, context.ConnectionAborted); + if (!validation.IsValid) + return false; + + if (validation.Session != null) + { + var httpContext = context.GetHttpContext(); + try + { + await _userSessionService.TouchAsync(validation.Session.UserSessionId, new RequestActivity + { + OccurredOn = DateTime.UtcNow, + IpAddress = httpContext?.Connection.RemoteIpAddress?.ToString(), + UserAgent = httpContext?.Request.Headers.UserAgent + }, context.ConnectionAborted); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "API eventing session activity update failed."); + } + } + + return true; + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "API eventing authentication state validation unavailable."); + return false; + } + } + + private static DateTime? GetIssuedOn(ClaimsPrincipal principal) + { + var value = principal.FindFirstValue(OpenIddictConstants.Claims.IssuedAt); + if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)) + { + try { return DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime; } + catch (ArgumentOutOfRangeException) { return null; } + } + + return DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, + out var parsed) ? parsed.ToUniversalTime() : null; + } + } +} diff --git a/Web/Resgrid.Web.Services/Middleware/SessionValidationMiddleware.cs b/Web/Resgrid.Web.Services/Middleware/SessionValidationMiddleware.cs new file mode 100644 index 000000000..eb8cba7da --- /dev/null +++ b/Web/Resgrid.Web.Services/Middleware/SessionValidationMiddleware.cs @@ -0,0 +1,124 @@ +using System; +using System.Globalization; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using OpenIddict.Abstractions; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; + +namespace Resgrid.Web.Services.Middleware +{ + /// + /// Enforces account-wide credential cutoffs and per-session revocation on every + /// authenticated user API request. Pre-feature tokens without a session claim remain + /// valid until their natural expiry unless the account has subsequently been revoked. + /// + public class SessionValidationMiddleware + { + private readonly RequestDelegate _next; + + public SessionValidationMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, IUserSessionService userSessionService) + { + var principal = context.User; + if (principal?.Identity?.IsAuthenticated != true) + { + await _next(context); + return; + } + + if (string.Equals(principal.FindFirstValue(SessionClaimTypes.WebEventingOnly), "true", + StringComparison.Ordinal)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? + principal.FindFirstValue(ClaimTypes.PrimarySid) ?? + principal.FindFirstValue(OpenIddictConstants.Claims.Subject); + + // Client-credential/system principals are not user sessions. + if (string.IsNullOrWhiteSpace(userId) || userId.StartsWith("dept_", StringComparison.Ordinal) || + userId.StartsWith("system_", StringComparison.Ordinal)) + { + await _next(context); + return; + } + + long? generation = null; + if (long.TryParse(principal.FindFirstValue(SessionClaimTypes.AuthenticationGeneration), + NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedGeneration)) + generation = parsedGeneration; + + int? departmentId = null; + if (int.TryParse(principal.FindFirstValue(ClaimTypes.PrimaryGroupSid), out var parsedDepartmentId)) + departmentId = parsedDepartmentId; + + SessionValidationResult validation; + try + { + validation = await userSessionService.ValidateAsync(new SessionPrincipalContext + { + UserId = userId, + SessionId = principal.FindFirstValue(SessionClaimTypes.SessionId), + AuthenticationGeneration = generation, + DepartmentId = departmentId, + CredentialIssuedOn = GetIssuedOn(principal) + }, context.RequestAborted); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "API authentication state validation unavailable."); + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + return; + } + + if (!validation.IsValid) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers.WWWAuthenticate = "Bearer error=\"invalid_token\""; + return; + } + + if (validation.Session != null) + { + try + { + await userSessionService.TouchAsync(validation.Session.UserSessionId, new RequestActivity + { + OccurredOn = DateTime.UtcNow, + IpAddress = IpAddressHelper.GetRequestIP(context.Request, true), + UserAgent = context.Request.Headers.UserAgent + }, context.RequestAborted); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "API session activity update failed."); + } + } + + await _next(context); + } + + private static DateTime? GetIssuedOn(ClaimsPrincipal principal) + { + var value = principal.FindFirstValue(OpenIddictConstants.Claims.IssuedAt); + if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)) + { + try { return DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime; } + catch (ArgumentOutOfRangeException) { return null; } + } + + return DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) + ? parsed.ToUniversalTime() + : null; + } + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 1a2033993..3109df675 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -2551,6 +2551,9 @@ DepartmentRightsResult object with the department rights and group memberships + + User authentication session inventory and revocation. + Unit roles @@ -5464,6 +5467,16 @@ The context. Task. + + Revalidates user session state for every invocation on an open API SignalR connection. + + + + Enforces account-wide credential cutoffs and per-session revocation on every + authenticated user API request. Pre-feature tokens without a session claim remain + valid until their natural expiry unless the account has subsequently been revoked. + + Authentication handler that validates requests bearing the X-Resgrid-SystemApiKey header. diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index 350ddb222..9cd61300a 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -67,10 +68,12 @@ public class Startup public ILifetimeScope AutofacContainer { get; private set; } public AutofacServiceLocator Locator { get; private set; } public IServiceCollection Services { get; private set; } + private readonly IHostingEnvironment _environment; //private MeterProvider meterProvider; public Startup(IHostingEnvironment env) { + _environment = env; var builder = new Microsoft.Extensions.Configuration.ConfigurationBuilder() .SetBasePath(env.ContentRootPath) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) @@ -116,6 +119,9 @@ public void ConfigureServices(IServiceCollection services) collection.SetValue(settings, true); element.SetValue(settings, true); + if (Config.ApiConfig.BypassSslChecks && !(_environment.IsDevelopment() || _environment.IsStaging())) + throw new InvalidOperationException("ApiConfig.BypassSslChecks cannot be enabled outside development or staging."); + if (Config.ApiConfig.BypassSslChecks) { services.AddHttpClient("ByPassSSLHttpClient") @@ -247,6 +253,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSignalR(hubOptions => { hubOptions.EnableDetailedErrors = true; + hubOptions.AddFilter(); }).AddStackExchangeRedis(CacheConfig.RedisConnectionString, options => { options.Configuration.ChannelPrefix = $"{Config.SystemBehaviorConfig.GetEnvPrefix()}resgrid-evt-sr"; @@ -545,7 +552,8 @@ public void ConfigureServices(IServiceCollection services) //.AllowHybridFlow() .AllowClientCredentialsFlow() .AllowPasswordFlow() - .AllowRefreshTokenFlow(); + .AllowRefreshTokenFlow() + .AllowCustomFlow("web_session"); // Accept anonymous clients (i.e clients that don't send a client_id). options.AcceptAnonymousClients(); @@ -679,11 +687,7 @@ public void ConfigureContainer(ContainerBuilder builder) // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) { - var forwardOpts = new ForwardedHeadersOptions - { - ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedFor - }; - app.UseForwardedHeaders(forwardOpts); + app.UseForwardedHeaders(); app.UseMiddleware(); //loggerFactory.AddConsole(Configuration.GetSection("Logging")); @@ -724,6 +728,7 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerF app.UseStaticFiles(); app.UseAuthentication(); + app.UseMiddleware(); app.UseAuthorization(); app.UseSwagger(); @@ -758,7 +763,7 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerF { endpoints.MapControllers(); - endpoints.MapHub("/eventingHub"); + endpoints.MapHub("/eventingHub", options => options.CloseOnAuthenticationExpiration = true); // Shallow liveness: process is up and serving requests, no external calls. // Point k8s liveness probes here. diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts index 673aa39ff..2f37631d8 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts @@ -8,7 +8,7 @@ import { type IRetryPolicy, type RetryContext, } from '@microsoft/signalr'; -import { getAccessToken } from '../../runtime/auth'; +import { getEventingToken } from '../../runtime/eventingToken'; import { getBrowserConfig } from '../../runtime/browserConfig'; import { getMessagesAfter } from './chatApi'; import { @@ -112,13 +112,9 @@ class ChatHub { return this.startPromise; } - if (getAccessToken().length === 0) { - return; - } - const { channelUrl } = getBrowserConfig(); const connection = new HubConnectionBuilder() - .withUrl(`${channelUrl}/chatHub`, { accessTokenFactory: () => getAccessToken() }) + .withUrl(`${channelUrl}/chatHub`, { accessTokenFactory: getEventingToken }) .withAutomaticReconnect(new CappedRetryPolicy()) .configureLogging(LogLevel.Warning) .build(); diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/runtime/api.ts b/Web/Resgrid.Web/Areas/User/Apps/src/runtime/api.ts index 39b3480a8..047ab6174 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/runtime/api.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/runtime/api.ts @@ -1,4 +1,3 @@ -import { getAccessToken } from './auth'; import { getBrowserConfig } from './browserConfig'; export class ApiError extends Error { @@ -15,7 +14,7 @@ export type ApiQuery = Record 0) { - headers.set('Authorization', `Bearer ${accessToken}`); - } return headers; } export async function apiFetchJson(path: string, init?: RequestInit, query?: ApiQuery): Promise { + const headers = apiAuthHeaders(init?.headers); + const method = (init?.method ?? 'GET').toUpperCase(); + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const token = document.querySelector('meta[name="request-verification-token"]')?.content; + if (token) headers.set('RequestVerificationToken', token); + } const response = await fetch(buildApiUrl(path, query), { ...init, - headers: apiAuthHeaders(init?.headers), + credentials: 'same-origin', + headers, }); if (!response.ok) { diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/runtime/auth.ts b/Web/Resgrid.Web/Areas/User/Apps/src/runtime/auth.ts deleted file mode 100644 index aed7e44ba..000000000 --- a/Web/Resgrid.Web/Areas/User/Apps/src/runtime/auth.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { getBrowserConfig } from './browserConfig'; - -export interface StoredTokens { - access_token: string; - refresh_token?: string; - id_token?: string; - expiration_date?: string; - [key: string]: unknown; -} - -export function getStoredTokens(): StoredTokens | null { - const storageKey = getBrowserConfig().tokenStorageKey; - const rawValue = window.localStorage.getItem(storageKey); - - if (!rawValue) { - return null; - } - - try { - const parsedValue = JSON.parse(rawValue) as StoredTokens; - - if (typeof parsedValue?.access_token === 'string' && parsedValue.access_token.length > 0) { - return parsedValue; - } - - return null; - } catch { - if (rawValue.trim().length > 0) { - return { - access_token: rawValue.trim(), - }; - } - - return null; - } -} - -export function getAccessToken(): string { - return getStoredTokens()?.access_token ?? ''; -} diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/runtime/browserConfig.ts b/Web/Resgrid.Web/Areas/User/Apps/src/runtime/browserConfig.ts index 9a2713b16..f53eb46fb 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/runtime/browserConfig.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/runtime/browserConfig.ts @@ -10,7 +10,6 @@ export interface BrowserConfig { apiBaseUrl: string; googleMapsKey: string; channelUrl: string; - tokenStorageKey: string; } function trimTrailingSlash(value: string): string { @@ -19,9 +18,8 @@ function trimTrailingSlash(value: string): string { export function getBrowserConfig(): BrowserConfig { return { - apiBaseUrl: trimTrailingSlash(window.rgApiBaseUrl?.trim() || 'https://api.resgrid.com'), + apiBaseUrl: `${window.location.origin}/api/web-bff`, googleMapsKey: window.rgGoogleMapsKey?.trim() || '', channelUrl: trimTrailingSlash(window.rgChannelUrl?.trim() || 'https://events.resgrid.com'), - tokenStorageKey: 'RgWebApp.auth-tokens', }; } diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/runtime/eventingToken.ts b/Web/Resgrid.Web/Areas/User/Apps/src/runtime/eventingToken.ts new file mode 100644 index 000000000..369eafc45 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Apps/src/runtime/eventingToken.ts @@ -0,0 +1,21 @@ +interface EventingTokenResponse { + accessToken: string; +} + +export async function getEventingToken(): Promise { + const verificationToken = document.querySelector( + 'meta[name="request-verification-token"]', + )?.content; + const headers = new Headers({ Accept: 'application/json' }); + if (verificationToken) headers.set('RequestVerificationToken', verificationToken); + + const response = await fetch('/api/web-bff/eventing-token', { + method: 'POST', + credentials: 'same-origin', + headers, + }); + if (!response.ok) return ''; + + const value = (await response.json()) as EventingTokenResponse; + return typeof value.accessToken === 'string' ? value.accessToken : ''; +} diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/runtime/signalr.ts b/Web/Resgrid.Web/Areas/User/Apps/src/runtime/signalr.ts index e248488c5..36752ee18 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/runtime/signalr.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/runtime/signalr.ts @@ -1,5 +1,5 @@ import { HubConnectionBuilder, LogLevel, type HubConnection } from '@microsoft/signalr'; -import { getAccessToken } from './auth'; +import { getEventingToken } from './eventingToken'; import { getBrowserConfig } from './browserConfig'; export interface PersonnelLocationUpdate { @@ -22,15 +22,9 @@ export interface GeolocationHandlers { } export async function connectGeolocationHub(handlers: GeolocationHandlers): Promise { - const accessToken = getAccessToken(); - - if (accessToken.length === 0) { - return null; - } - const { channelUrl } = getBrowserConfig(); const connection = new HubConnectionBuilder() - .withUrl(`${channelUrl}/geolocationHub?access_token=${encodeURIComponent(accessToken)}`) + .withUrl(`${channelUrl}/geolocationHub`, { accessTokenFactory: getEventingToken }) .withAutomaticReconnect() .configureLogging(LogLevel.Information) .build(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/AccountController.cs b/Web/Resgrid.Web/Areas/User/Controllers/AccountController.cs index 9b9fc862d..052cc0989 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/AccountController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/AccountController.cs @@ -2,6 +2,8 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Mvc; using Resgrid.Model; using Resgrid.Model.Services; @@ -65,6 +67,7 @@ public async Task DeleteAccount() [HttpPost] [Authorize(Roles = SystemRoles.Users)] + [ValidateAntiForgeryToken] public async Task DeleteAccount(DeleteAccountModel model, CancellationToken cancellationToken) { if (model.AreYouSure == false) @@ -87,7 +90,8 @@ await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit }, cancellationToken); await _deleteService.DeleteUserAccountAsync(DepartmentId, UserId, UserId, IpAddressHelper.GetRequestIP(Request, true), $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}", cancellationToken); - return RedirectToAction("LogOff", "Account", new { area = "" }); + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return RedirectToAction("LogOn", "Account", new { area = "" }); } return View(model); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs new file mode 100644 index 000000000..bfa8d1178 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs @@ -0,0 +1,236 @@ +using System; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Identity; +using Resgrid.Model; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Models.Security; +using Resgrid.Web.Helpers; +using IdentityUser = Resgrid.Model.Identity.IdentityUser; + +namespace Resgrid.Web.Areas.User.Controllers +{ + [Area("User")] + public class AccountSecurityController : SecureBaseController + { + private readonly IUserSessionService _userSessionService; + private readonly ISystemAuditsService _systemAuditsService; + private readonly UserManager _userManager; + private readonly IDepartmentSsoService _departmentSsoService; + private readonly IExternalIdentityLinkService _externalIdentityLinkService; + private readonly IDepartmentsService _departmentsService; + + public AccountSecurityController(IUserSessionService userSessionService, ISystemAuditsService systemAuditsService, + UserManager userManager, IDepartmentSsoService departmentSsoService, + IExternalIdentityLinkService externalIdentityLinkService, IDepartmentsService departmentsService) + { + _userSessionService = userSessionService; + _systemAuditsService = systemAuditsService; + _userManager = userManager; + _departmentSsoService = departmentSsoService; + _externalIdentityLinkService = externalIdentityLinkService; + _departmentsService = departmentsService; + } + + [HttpGet] + public async Task ChangeUsername(CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(UserId); + var model = new ChangeUsernameView + { + CurrentUsername = user?.UserName, + IsSsoManaged = await IsSsoManagedAsync(cancellationToken) + }; + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ChangeUsername(ChangeUsernameView model, CancellationToken cancellationToken) + { + model.IsSsoManaged = await IsSsoManagedAsync(cancellationToken); + var user = await _userManager.FindByIdAsync(UserId); + model.CurrentUsername = user?.UserName; + if (model.IsSsoManaged) + ModelState.AddModelError(string.Empty, "This account is managed by SSO. Its username cannot be changed in Resgrid."); + if (user == null || !await _userManager.CheckPasswordAsync(user, model.CurrentPassword ?? string.Empty)) + ModelState.AddModelError(nameof(model.CurrentPassword), "The current password is incorrect."); + if (await _userManager.FindByNameAsync(model.NewUsername ?? string.Empty) is IdentityUser existing && existing.Id != UserId) + ModelState.AddModelError(nameof(model.NewUsername), "That username is already in use."); + if (!ModelState.IsValid) + return View(model); + + var now = DateTime.UtcNow; + user.AuthenticationGeneration++; + user.CredentialsValidAfterUtc = now; + user.AuthenticationStateChangedOn = now; + var change = await _userManager.SetUserNameAsync(user, model.NewUsername.Trim()); + if (!change.Succeeded) + { + foreach (var error in change.Errors) ModelState.AddModelError(string.Empty, error.Description); + return View(model); + } + + await _userSessionService.RevokeAllAfterCredentialChangeAsync(UserId, UserId, + UserSessionRevocationReason.UsernameChanged, now, cancellationToken); + await AuditAsync(SystemAuditTypes.UsernameChanged, null, true, cancellationToken); + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return RedirectToAction("LogOn", "Account", new { area = "", reason = "username-changed" }); + } + + [HttpGet] + public async Task ChangePassword(CancellationToken cancellationToken) + { + return View(new ChangePasswordView + { + MinPasswordLength = await _departmentSsoService.GetEffectiveMinPasswordLengthAsync(DepartmentId), + IsSsoManaged = await IsSsoManagedAsync(cancellationToken) + }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ChangePassword(ChangePasswordView model, CancellationToken cancellationToken) + { + model.MinPasswordLength = await _departmentSsoService.GetEffectiveMinPasswordLengthAsync(DepartmentId); + model.IsSsoManaged = await IsSsoManagedAsync(cancellationToken); + if (model.IsSsoManaged) + ModelState.AddModelError(string.Empty, "This account is managed by SSO. Its password cannot be changed in Resgrid."); + + var policyError = await _departmentSsoService.ValidatePasswordAgainstPolicyAsync(DepartmentId, model.NewPassword); + if (policyError != null) + ModelState.AddModelError(nameof(model.NewPassword), policyError); + if (!ModelState.IsValid) + return View(model); + + var user = await _userManager.FindByIdAsync(UserId); + if (user == null) + return NotFound(); + + var now = DateTime.UtcNow; + user.AuthenticationGeneration++; + user.CredentialsValidAfterUtc = now; + user.AuthenticationStateChangedOn = now; + var change = await _userManager.ChangePasswordAsync(user, model.CurrentPassword, model.NewPassword); + if (!change.Succeeded) + { + foreach (var error in change.Errors) ModelState.AddModelError(string.Empty, error.Description); + return View(model); + } + + await _departmentSsoService.RecordPasswordChangedAsync(DepartmentId, UserId, cancellationToken); + await _userSessionService.RevokeAllAfterCredentialChangeAsync(UserId, UserId, + UserSessionRevocationReason.PasswordChanged, now, cancellationToken); + await AuditAsync(SystemAuditTypes.PasswordChanged, null, true, cancellationToken); + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return RedirectToAction("LogOn", "Account", new { area = "", reason = "password-changed" }); + } + + [HttpGet] + public async Task Sessions(CancellationToken cancellationToken) + { + var model = new ActiveSessionsView + { + CurrentSessionId = User.FindFirstValue(SessionClaimTypes.SessionId), + Sessions = await _userSessionService.GetActiveForUserAsync(UserId, cancellationToken) + }; + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RevokeSession(string id, CancellationToken cancellationToken) + { + var currentSessionId = User.FindFirstValue(SessionClaimTypes.SessionId); + var result = await _userSessionService.RevokeSessionAsync(UserId, UserId, id, + UserSessionRevocationReason.UserRevoked, cancellationToken); + await AuditAsync(SystemAuditTypes.SessionRevoked, id, result.RevokedSessionCount > 0, cancellationToken); + + if (string.Equals(currentSessionId, id, StringComparison.Ordinal)) + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return RedirectToAction("LogOn", "Account", new { area = "" }); + } + + TempData["SessionMessage"] = result.RevokedSessionCount > 0 + ? "The selected session was signed out." + : "That session was already inactive."; + return RedirectToAction(nameof(Sessions)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RevokeOtherSessions(CancellationToken cancellationToken) + { + var currentSessionId = User.FindFirstValue(SessionClaimTypes.SessionId); + if (string.IsNullOrWhiteSpace(currentSessionId)) + { + TempData["SessionMessage"] = "This legacy session must be refreshed before other sessions can be distinguished safely."; + return RedirectToAction(nameof(Sessions)); + } + + var result = await _userSessionService.RevokeOtherSessionsAsync(UserId, currentSessionId, + UserSessionRevocationReason.OtherSessionsRevoked, cancellationToken); + await AuditAsync(SystemAuditTypes.OtherSessionsRevoked, null, true, cancellationToken); + TempData["SessionMessage"] = $"Signed out {result.RevokedSessionCount} other session(s)."; + return RedirectToAction(nameof(Sessions)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RevokeAllSessions(CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var result = await _userSessionService.RevokeAllAsync(UserId, UserId, + UserSessionRevocationReason.AccountCompromised, now, cancellationToken); + await AuditAsync(SystemAuditTypes.AllSessionsRevoked, null, true, cancellationToken); + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return RedirectToAction("LogOn", "Account", new { area = "", reason = "sessions-revoked" }); + } + + private Task AuditAsync(SystemAuditTypes type, string sessionId, bool successful, CancellationToken cancellationToken) + { + return _systemAuditsService.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Website, + Type = (int)type, + UserId = UserId, + TargetUserId = UserId, + SessionId = SessionSupportSuffix(sessionId), + Successful = successful, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + CorrelationId = HttpContext.TraceIdentifier, + Data = $"Account session operation. Agent={BoundAuditValue(Request.Headers.UserAgent.ToString(), 256)}", + LoggedOn = DateTime.UtcNow + }, cancellationToken); + } + + private static string BoundAuditValue(string value, int maximumLength) + { + if (string.IsNullOrWhiteSpace(value)) return "Unknown"; + var sanitized = value.Replace("\r", " ").Replace("\n", " ").Trim(); + return sanitized.Length <= maximumLength ? sanitized : sanitized.Substring(0, maximumLength); + } + + private static string SessionSupportSuffix(string sessionId) => + string.IsNullOrWhiteSpace(sessionId) || sessionId.Length <= 8 + ? sessionId + : sessionId.Substring(sessionId.Length - 8); + + private async Task IsSsoManagedAsync(CancellationToken cancellationToken) + { + var state = await _externalIdentityLinkService.GetSsoManagementStateAsync(UserId, cancellationToken); + if (state.IsSsoManaged) + return true; + + var member = await _departmentsService.GetDepartmentMemberAsync(UserId, DepartmentId); + return member != null && (!string.IsNullOrWhiteSpace(member.ExternalSsoId) || member.SsoLinkedOn.HasValue); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index 18d1ea2e5..401abdf9c 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -255,6 +255,7 @@ public async Task Settings() var activeCallRssKey = await _departmentSettingsService.GetRssKeyForDepartmentAsync(DepartmentId); model.DisableAutoAvailable = await _departmentSettingsService.GetDisableAutoAvailableForDepartmentAsync(DepartmentId); model.EnableModernNotifications = await _departmentSettingsService.GetModernNotificationsEnabledAsync(DepartmentId); + model.RequirePasswordResetViaEmail = await _departmentSettingsService.GetRequirePasswordResetViaEmailAsync(DepartmentId); model.ForceChatbotSecurityPin = await _departmentSettingsService.GetForceChatbotSecurityPinAsync(DepartmentId); model.TtsLanguage = await _departmentSettingsService.GetTtsLanguageForDepartmentAsync(DepartmentId); model.TtsLanguages = BuildTtsLanguageSelectList(model.TtsLanguage); @@ -553,6 +554,8 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.Di cancellationToken); await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.EnableModernNotifications.ToString(), DepartmentSettingTypes.EnableModernNotifications, cancellationToken); + await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.RequirePasswordResetViaEmail.ToString(), + DepartmentSettingTypes.RequirePasswordResetViaEmail, cancellationToken); var forcePinWasEnabled = await _departmentSettingsService.GetForceChatbotSecurityPinAsync(DepartmentId, bypassCache: true); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs index 744dab20e..5ba0f5d4a 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs @@ -33,6 +33,9 @@ using Resgrid.Localization; using Microsoft.AspNetCore.Localization; using System.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Resgrid.Model.Security; namespace Resgrid.Web.Areas.User.Controllers { @@ -73,6 +76,8 @@ public class HomeController : SecureBaseController private readonly IPhoneNumberProcesserProvider _phoneNumberProcesser; private readonly ISecurityPinService _securityPinService; private readonly IEncryptionService _encryptionService; + private readonly IExternalIdentityLinkService _externalIdentityLinkService; + private readonly IUserSessionService _userSessionService; public HomeController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, IUserStateService userStateService, IDepartmentGroupsService departmentGroupsService, Resgrid.Model.Services.IAuthorizationService authorizationService, @@ -83,7 +88,8 @@ public HomeController(IDepartmentsService departmentsService, IUsersService user IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, IDepartmentSsoService departmentSsoService, IStringLocalizer secLocalizer, IGdprDataExportService gdprDataExportService, ISystemAuditsService systemAuditsService, IPhoneNumberProcesserProvider phoneNumberProcesser, - ISecurityPinService securityPinService, IEncryptionService encryptionService) + ISecurityPinService securityPinService, IEncryptionService encryptionService, + IExternalIdentityLinkService externalIdentityLinkService, IUserSessionService userSessionService) { _departmentsService = departmentsService; _usersService = usersService; @@ -115,6 +121,8 @@ public HomeController(IDepartmentsService departmentsService, IUsersService user _phoneNumberProcesser = phoneNumberProcesser; _securityPinService = securityPinService; _encryptionService = encryptionService; + _externalIdentityLinkService = externalIdentityLinkService; + _userSessionService = userSessionService; _localizer = factory.Create("Home.Dashboard", new AssemblyName(typeof(SupportedLocales).GetTypeInfo().Assembly.FullName).Name); } @@ -502,8 +510,16 @@ public async Task EditUserProfile(string userId) model.UdfFormHtml = _udfRenderingService.GenerateHtmlFormFields(udfDefinition, udfFields, filteredValues); } - if (model.IsOwnProfile) - model.MinPasswordLength = await _departmentSsoService.GetEffectiveMinPasswordLengthAsync(DepartmentId); + var externalIdentityState = await _externalIdentityLinkService.GetSsoManagementStateAsync(userId); + var isLegacySsoLinked = departmentMember != null && + (!string.IsNullOrWhiteSpace(departmentMember.ExternalSsoId) || departmentMember.SsoLinkedOn.HasValue); + model.CanManageLocalCredentials = model.IsOwnProfile && !externalIdentityState.IsSsoManaged && !isLegacySsoLinked; + model.IsSsoManaged = externalIdentityState.IsSsoManaged || isLegacySsoLinked; + model.IsEmailExternallyManaged = externalIdentityState.IsEmailExternallyManaged || isLegacySsoLinked; + model.CanResetPassword = !model.IsOwnProfile && userId != model.Department.ManagingUserId && + (model.Department.IsUserAnAdmin(UserId) || + (group != null && group.IsUserGroupAdmin(UserId) && !model.Department.IsUserAnAdmin(userId))); + model.RequirePasswordResetViaEmail = await _departmentSettingsService.GetRequirePasswordResetViaEmailAsync(DepartmentId); if (model.IsOwnProfile) model.ActiveDataExportRequest = await _gdprDataExportService.GetActiveRequestByUserIdAsync(userId); @@ -534,8 +550,25 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo bool callerIsGroupAdmin = await _departmentGroupsService.IsUserAGroupAdminAsync(UserId, DepartmentId); model.User = _usersService.GetUserById(model.UserId); - //model.PushUris = await _pushUriService.GetPushUrisByUserId(model.UserId); + if (model.User == null) + return NotFound(); + + var targetDepartmentMember = await _departmentsService.GetDepartmentMemberAsync(model.UserId, DepartmentId); + var targetExternalIdentityState = await _externalIdentityLinkService.GetSsoManagementStateAsync(model.UserId, cancellationToken); + var isLegacySsoLinkedOnPost = targetDepartmentMember != null && + (!string.IsNullOrWhiteSpace(targetDepartmentMember.ExternalSsoId) || targetDepartmentMember.SsoLinkedOn.HasValue); + model.CanManageLocalCredentials = model.IsOwnProfile && !targetExternalIdentityState.IsSsoManaged && !isLegacySsoLinkedOnPost; + model.IsSsoManaged = targetExternalIdentityState.IsSsoManaged || isLegacySsoLinkedOnPost; + model.IsEmailExternallyManaged = targetExternalIdentityState.IsEmailExternallyManaged || isLegacySsoLinkedOnPost; model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var targetGroupForPasswordReset = await _departmentGroupsService.GetGroupForUserAsync(model.UserId, DepartmentId); + model.CanResetPassword = !model.IsOwnProfile && model.UserId != model.Department?.ManagingUserId && + (model.Department?.IsUserAnAdmin(UserId) == true || + (targetGroupForPasswordReset != null && targetGroupForPasswordReset.IsUserGroupAdmin(UserId) && + model.Department?.IsUserAnAdmin(model.UserId) != true)); + model.RequirePasswordResetViaEmail = await _departmentSettingsService.GetRequirePasswordResetViaEmailAsync(DepartmentId); + var emailChanged = !string.Equals(model.User.Email, model.Email, StringComparison.OrdinalIgnoreCase); + //model.PushUris = await _pushUriService.GetPushUrisByUserId(model.UserId); model.CanEnableVoice = await _limitsService.CanDepartmentUseVoiceAsync(DepartmentId); var groups = new List(); @@ -629,12 +662,16 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo ModelState.AddModelError("State", string.Format("The Mailing State/Provence field is required")); } - if (model.User.Email != model.Email) + if (emailChanged) { + if (model.IsEmailExternallyManaged) + { + ModelState.AddModelError("Email", "This email address is managed by the linked SSO provider and cannot be changed in Resgrid."); + } // SECURITY: Email changes are high-privilege — only the account owner or a department // admin may change an email address. A group admin must NOT be able to change a // member's email because that enables account-takeover via the password-reset flow. - if (!model.IsOwnProfile && !callerIsDepartmentAdmin) + else if (!model.IsOwnProfile && !callerIsDepartmentAdmin) { ModelState.AddModelError("Email", "You do not have permission to change this user's email address."); } @@ -661,39 +698,6 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo if (model.IsOwnProfile) { - bool checkPasswordSuccess = false; - if (string.IsNullOrEmpty(model.OldPassword) == false && string.IsNullOrEmpty(model.NewPassword) == false) - { - try - { - checkPasswordSuccess = await _userManager.CheckPasswordAsync(model.User, model.OldPassword); - } - catch (Exception) - { - checkPasswordSuccess = false; - } - - if (!checkPasswordSuccess) - { - ModelState.AddModelError("", "The current password is incorrect or the new password is invalid."); - } - else - { - // Validate new password against system-enforced complexity and department min-length policy - var policyError = await _departmentSsoService.ValidatePasswordAgainstPolicyAsync(DepartmentId, model.NewPassword); - if (policyError != null) - ModelState.AddModelError("NewPassword", ResolvePwdError(policyError)); - } - } - - if (!String.IsNullOrWhiteSpace(model.NewUsername)) - { - var newUser = await _userManager.FindByNameAsync(model.NewUsername); - - if (newUser != null) - ModelState.AddModelError("", "The NEW username you have supplied is already in use, please try another one. If you didn't mean to update your username please leave that field blank."); - } - if (!String.IsNullOrWhiteSpace(model.SecurityPin)) { // Normalize once so validation sees the same value that gets encrypted on save. @@ -888,24 +892,43 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo await _departmentsService.SaveDepartmentMemberAsync(depMember, cancellationToken); } - // SECURITY: Only the account owner or a department admin may update the email address. - if (model.IsOwnProfile || callerIsDepartmentAdmin) - _usersService.UpdateEmail(model.User.Id, model.Email); - - if (model.IsOwnProfile) + var signedOutByEmailChange = false; + // Email is a login/recovery identifier. Persist it through UserManager and revoke every + // credential immediately; SSO-managed email was rejected before entering this block. + if (emailChanged && !model.IsEmailExternallyManaged && (model.IsOwnProfile || callerIsDepartmentAdmin)) { - // Change Password - if (!string.IsNullOrEmpty(model.OldPassword) && !string.IsNullOrEmpty(model.NewPassword)) + var identityUser = await _userManager.FindByIdAsync(model.User.Id); + if (identityUser != null) { - var identityUser = await _userManager.FindByIdAsync(model.User.Id); - var result = await _userManager.ChangePasswordAsync(identityUser, model.OldPassword, model.NewPassword); - if (result.Succeeded) - await _departmentSsoService.RecordPasswordChangedAsync(DepartmentId, model.User.Id, cancellationToken); - } - - if (!string.IsNullOrWhiteSpace(model.NewUsername)) - { - await _usersService.UpdateUsername(model.User.UserName, model.NewUsername); + var now = DateTime.UtcNow; + identityUser.AuthenticationGeneration++; + identityUser.CredentialsValidAfterUtc = now; + identityUser.AuthenticationStateChangedOn = now; + var changeEmailResult = await _userManager.SetEmailAsync(identityUser, model.Email); + if (changeEmailResult.Succeeded) + { + await _userSessionService.RevokeAllAfterCredentialChangeAsync(UserId, model.UserId, + UserSessionRevocationReason.EmailChanged, now, cancellationToken); + await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Website, + Type = (int)SystemAuditTypes.EmailChanged, + UserId = UserId, + TargetUserId = model.UserId, + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + CorrelationId = HttpContext.TraceIdentifier, + Data = "Account email changed; all sessions and tokens revoked.", + LoggedOn = now + }, cancellationToken); + signedOutByEmailChange = model.IsOwnProfile; + } + else + { + foreach (var error in changeEmailResult.Errors) + ModelState.AddModelError("Email", error.Description); + } } } @@ -955,9 +978,6 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo model.UdfFormHtml = _udfRenderingService.GenerateHtmlFormFields(udfDefinitionOnUdfError, udfFieldsOnUdfError, filteredValuesOnUdfError); } - if (model.IsOwnProfile) - model.MinPasswordLength = await _departmentSsoService.GetEffectiveMinPasswordLengthAsync(DepartmentId); - return View(model); } @@ -976,6 +996,12 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo Thread.CurrentThread.CurrentUICulture = System.Globalization.CultureInfo.GetCultureInfo(savedProfile.Language); } + if (signedOutByEmailChange) + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return RedirectToAction("LogOn", "Account", new { area = "", reason = "email-changed" }); + } + return RedirectToAction("Index", "Personnel", new { area = "User" }); } @@ -993,9 +1019,6 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo model.UdfFormHtml = _udfRenderingService.GenerateHtmlFormFields(udfDefinitionOnFailure, udfFieldsOnFailure, filteredValuesOnFailure); } - if (model.IsOwnProfile) - model.MinPasswordLength = await _departmentSsoService.GetEffectiveMinPasswordLengthAsync(DepartmentId); - return View(model); } #endregion Edit User Profile diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index 6c6327886..0f84e02b8 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -61,6 +61,7 @@ public class PersonnelController : SecureBaseController private readonly IUdfRenderingService _udfRenderingService; private readonly IStringLocalizer _localizer; private readonly IPhoneNumberProcesserProvider _phoneNumberProcesser; + private readonly IExternalIdentityLinkService _externalIdentityLinkService; public PersonnelController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, IEmailService emailService, IUserProfileService userProfileService, IDeleteService deleteService, Model.Services.IAuthorizationService authorizationService, @@ -68,7 +69,8 @@ public PersonnelController(IDepartmentsService departmentsService, IUsersService IEventAggregator eventAggregator, IEmailMarketingProvider emailMarketingProvider, ICertificationService certificationService, ICustomStateService customStateService, IGeoService geoService, UserManager userManager, IDepartmentSettingsService departmentSettingsService, ICallsService callsService, IGeoLocationProvider geoLocationProvider, IMappingService mappingService, IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, - IStringLocalizer localizer, IPhoneNumberProcesserProvider phoneNumberProcesser) + IStringLocalizer localizer, IPhoneNumberProcesserProvider phoneNumberProcesser, + IExternalIdentityLinkService externalIdentityLinkService) { _departmentsService = departmentsService; _usersService = usersService; @@ -95,6 +97,7 @@ public PersonnelController(IDepartmentsService departmentsService, IUsersService _udfRenderingService = udfRenderingService; _localizer = localizer; _phoneNumberProcesser = phoneNumberProcesser; + _externalIdentityLinkService = externalIdentityLinkService; } #endregion Private Members and Constructors @@ -108,6 +111,7 @@ public async Task Index() model.CanAddNewUser = await _limitsService.CanDepartmentAddNewUserAsync(DepartmentId); model.CanGroupAdminsAdd = await _authorizationService.CanGroupAdminsAddUsersAsync(DepartmentId); + model.RequirePasswordResetViaEmail = await _departmentSettingsService.GetRequirePasswordResetViaEmailAsync(DepartmentId); var personnelStates = await _customStateService.GetActivePersonnelStateForDepartmentAsync(DepartmentId); var personnelStaffing = await _customStateService.GetActiveStaffingLevelsForDepartmentAsync(DepartmentId); @@ -149,6 +153,9 @@ public async Task Index() person.UserId = user.UserId.ToString(); var member = departmentMembers.FirstOrDefault(x => x.UserId == user.UserId); + var externalState = await _externalIdentityLinkService.GetSsoManagementStateAsync(user.UserId); + person.IsSsoManaged = externalState.IsSsoManaged || + (member != null && (!string.IsNullOrWhiteSpace(member.ExternalSsoId) || member.SsoLinkedOn.HasValue)); // Skip hidden/disabled users unless current user is dept admin or group admin of their group if (member != null && ((member.IsDisabled.HasValue && member.IsDisabled.Value) || (member.IsHidden.HasValue && member.IsHidden.Value))) @@ -203,6 +210,11 @@ public async Task Index() } } + person.CanResetPassword = user.UserId != UserId && + user.UserId != department.ManagingUserId && + (department.IsUserAnAdmin(UserId) || + (group != null && group.IsUserGroupAdmin(UserId) && !department.IsUserAnAdmin(user.UserId))); + var userGroupRole = userGroupRoles.FirstOrDefault(x => x.UserId == user.UserId); if (userGroupRole != null) person.Roles = userGroupRole.RoleNames; @@ -603,7 +615,7 @@ public async Task AddPerson(AddPersonModel model, IFormCollection _usersService.ClearCacheForDepartment(DepartmentId); if (model.SendAccountCreationNotification) - await _emailService.SendWelcomeEmail(model.Department.Name, model.FirstName + " " + model.LastName, user.Email, user.UserName, model.ConfirmPassword, DepartmentId); + await _emailService.SendWelcomeEmail(model.Department.Name, model.FirstName + " " + model.LastName, user.Email, user.UserName, DepartmentId); await _emailMarketingProvider.SubscribeUserToUsersList(model.FirstName, model.LastName, user.Email); @@ -966,6 +978,9 @@ public async Task GetPersonnelList() var person = new PersonnelForListJson(); person.UserId = user.UserId.ToString(); + var externalState = await _externalIdentityLinkService.GetSsoManagementStateAsync(user.UserId); + person.IsSsoManaged = externalState.IsSsoManaged || + (member != null && (!string.IsNullOrWhiteSpace(member.ExternalSsoId) || member.SsoLinkedOn.HasValue)); //var actionLog = actionLogs.FirstOrDefault(x => x.UserId == user.UserId); //var userProfile = await _userProfileService.GetProfileByUserId(user.UserId); @@ -1009,6 +1024,11 @@ public async Task GetPersonnelList() } } + person.CanResetPassword = user.UserId != UserId && + user.UserId != department.ManagingUserId && + (department.IsUserAnAdmin(UserId) || + (group != null && group.IsUserGroupAdmin(UserId) && !department.IsUserAnAdmin(user.UserId))); + //var roles = await _personnelRolesService.GetRolesForUser(user.UserId); //foreach (var role in roles) //{ @@ -1111,6 +1131,9 @@ public async Task GetPersonnelListPaged(int perPage, int page) var person = new PersonnelForListJson(); person.UserId = user.UserId.ToString(); + var externalState = await _externalIdentityLinkService.GetSsoManagementStateAsync(user.UserId); + person.IsSsoManaged = externalState.IsSsoManaged || + (member != null && (!string.IsNullOrWhiteSpace(member.ExternalSsoId) || member.SsoLinkedOn.HasValue)); //var actionLog = actionLogs.FirstOrDefault(x => x.UserId == user.UserId); //var userProfile = await _userProfileService.GetProfileByUserId(user.UserId); @@ -1152,6 +1175,11 @@ public async Task GetPersonnelListPaged(int perPage, int page) } } + person.CanResetPassword = user.UserId != UserId && + user.UserId != department.ManagingUserId && + (department.IsUserAnAdmin(UserId) || + (group != null && group.IsUserGroupAdmin(UserId) && !department.IsUserAnAdmin(user.UserId))); + //var roles = await _personnelRolesService.GetRolesForUser(user.UserId); //foreach (var role in roles) //{ diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs index 5befe8672..4e6953bda 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs @@ -23,12 +23,14 @@ using Resgrid.Web.Options; using Microsoft.AspNetCore.Identity; using System.Threading.Tasks; +using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Resgrid.Web.Areas.User.Models.Personnel; using IdentityUser = Resgrid.Model.Identity.IdentityUser; using SixLabors.ImageSharp.Formats; using Microsoft.Extensions.Localization; +using Resgrid.Model.Security; namespace Resgrid.Web.Areas.User.Controllers { @@ -51,13 +53,22 @@ public class ProfileController : SecureBaseController private readonly IDepartmentSsoService _departmentSsoService; private readonly IStringLocalizer _secLocalizer; private readonly IDeleteService _deleteService; + private readonly IExternalIdentityLinkService _externalIdentityLinkService; + private readonly IUserSessionService _userSessionService; + private readonly ISystemAuditsService _systemAuditsService; + private readonly IDepartmentGroupsService _departmentGroupsService; + private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly IPasswordRecoveryService _passwordRecoveryService; public ProfileController(IDepartmentsService departmentsService, IUsersService usersService, Model.Services.IAuthorizationService authorizationService, IUserProfileService userProfileService, IScheduledTasksService scheduledTasksService, ICertificationService certificationService, ICustomStateService customStateService, IImageService imageService, IOptions appOptionsAccessor, IEmailService emailService, UserManager userManager, SignInManager signInManager, IDepartmentSsoService departmentSsoService, - IStringLocalizer secLocalizer, IDeleteService deleteService) + IStringLocalizer secLocalizer, IDeleteService deleteService, + IExternalIdentityLinkService externalIdentityLinkService, IUserSessionService userSessionService, + ISystemAuditsService systemAuditsService, IDepartmentGroupsService departmentGroupsService, + IDepartmentSettingsService departmentSettingsService, IPasswordRecoveryService passwordRecoveryService) { _departmentsService = departmentsService; _usersService = usersService; @@ -74,6 +85,12 @@ public ProfileController(IDepartmentsService departmentsService, IUsersService u _departmentSsoService = departmentSsoService; _secLocalizer = secLocalizer; _deleteService = deleteService; + _externalIdentityLinkService = externalIdentityLinkService; + _userSessionService = userSessionService; + _systemAuditsService = systemAuditsService; + _departmentGroupsService = departmentGroupsService; + _departmentSettingsService = departmentSettingsService; + _passwordRecoveryService = passwordRecoveryService; } #endregion Private Members and Constructors @@ -951,20 +968,28 @@ public async Task GetDepartmentCertificationTypes() [HttpGet] [Authorize(Policy = ResgridResources.Profile_View)] - public async Task ResetPasswordForUser(string userId) + public async Task ResetPasswordForUser(string userId) { - if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + var member = await _departmentsService.GetDepartmentMemberAsync(userId, DepartmentId); + if (member == null) + return NotFound(); + if (!await CanAdministratorResetPasswordAsync(userId, member)) return Unauthorized(); - var model = new ResetPasswordForUserView(); - model.UserId = userId; + var user = await _userManager.FindByIdAsync(userId); + if (user == null) + return NotFound(); - var user = _usersService.GetUserById(userId); - model.Name = await UserHelper.GetFullNameForUser(userId); - model.Email = user.Email; - model.Username = user.UserName; - model.MinPasswordLength = await _departmentSsoService.GetEffectiveMinPasswordLengthAsync(DepartmentId); - model.MustChangePasswordOnLogin = true; + var model = new ResetPasswordForUserView + { + UserId = userId, + MustChangePasswordOnLogin = true + }; + await PopulatePasswordResetModelAsync(model, user, member); + model.Message = TempData["PasswordResetMessage"] as string; + + if (model.IsSsoManaged) + return Forbid(); return View(model); } @@ -972,24 +997,43 @@ public async Task ResetPasswordForUser(string userId) [HttpPost] [Authorize(Policy = ResgridResources.Profile_View)] [ValidateAntiForgeryToken] - public async Task ResetPasswordForUser(ResetPasswordForUserView model) + public async Task ResetPasswordForUser(ResetPasswordForUserView model, CancellationToken cancellationToken) { - if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) - return Unauthorized(); - - var department= await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var user = await _userManager.FindByIdAsync(model.UserId); + if (user == null) + return NotFound(); - if (model.UserId == department.ManagingUserId) + var member = await _departmentsService.GetDepartmentMemberAsync(model.UserId, DepartmentId); + if (member == null) + return NotFound(); + if (!await CanAdministratorResetPasswordAsync(model.UserId, member)) return Unauthorized(); - var userDepartment = await _departmentsService.GetDepartmentByUserIdAsync(model.UserId); + await PopulatePasswordResetModelAsync(model, user, member); + if (model.IsSsoManaged) + return Forbid(); - if (department.DepartmentId != userDepartment.DepartmentId) - return Unauthorized(); + if (model.UseEmailResetLink) + { + // The mode is derived from durable department policy. Ignore any password fields or + // mode value supplied by the browser so a direct POST cannot bypass the setting. + ModelState.Remove(nameof(model.Password)); + ModelState.Remove(nameof(model.ConfirmPassword)); + model.Password = null; + model.ConfirmPassword = null; + + if (string.IsNullOrWhiteSpace(user.Email) || !await _userManager.IsEmailConfirmedAsync(user)) + { + ModelState.AddModelError(string.Empty, + "A reset link cannot be sent until this user has a confirmed email address."); + return View(model); + } - var user = await _userManager.FindByIdAsync(model.UserId); - model.Name = await UserHelper.GetFullNameForUser(model.UserId); - model.Email = user.Email; + return await SendAdministratorPasswordResetLinkAsync(model, user, cancellationToken); + } + + if (!ModelState.IsValid) + return View(model); // Validate new password against system-enforced complexity and department min-length policy var policyError = await _departmentSsoService.ValidatePasswordAgainstPolicyAsync(DepartmentId, model.Password); @@ -999,22 +1043,42 @@ public async Task ResetPasswordForUser(ResetPasswordForUserView return View(model); } + var now = DateTime.UtcNow; + user.AuthenticationGeneration++; + user.CredentialsValidAfterUtc = now; + user.AuthenticationStateChangedOn = now; var token = await _userManager.GeneratePasswordResetTokenAsync(user); var result = await _userManager.ResetPasswordAsync(user, token, model.Password); if (result.Succeeded) { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); await _departmentSsoService.RecordPasswordChangedAsync(DepartmentId, model.UserId); - var member = await _departmentsService.GetDepartmentMemberAsync(model.UserId, DepartmentId); - if (member != null) + member.MustChangePassword = model.MustChangePasswordOnLogin; + await _departmentsService.SaveDepartmentMemberAsync(member, cancellationToken); + + await _userSessionService.RevokeAllAfterCredentialChangeAsync(UserId, model.UserId, + UserSessionRevocationReason.PasswordReset, now, cancellationToken); + + await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit { - member.MustChangePassword = model.MustChangePasswordOnLogin; - await _departmentsService.SaveDepartmentMemberAsync(member); - } + System = (int)SystemAuditSystems.Website, + Type = (int)SystemAuditTypes.PasswordResetByAdministrator, + DepartmentId = DepartmentId, + UserId = UserId, + TargetUserId = model.UserId, + Username = User.Identity?.Name, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + CorrelationId = HttpContext.TraceIdentifier, + ServerName = Environment.MachineName, + Successful = true, + Data = $"Administrator reset the password and revoked all authentication sessions. MustChangePassword={model.MustChangePasswordOnLogin}." + }, cancellationToken); if (model.EmailUser) - await _emailService.SendPasswordResetEmail(model.Email, model.Name, user.UserName, model.Password, userDepartment.Name); + await _emailService.SendPasswordChangedByAdministratorEmail(model.Email, model.Name, + user.UserName, department?.Name ?? "Resgrid"); return RedirectToAction("Index", "Personnel", new { Area = "User" }); } @@ -1027,6 +1091,139 @@ public async Task ResetPasswordForUser(ResetPasswordForUserView return View(model); } + private async Task PopulatePasswordResetModelAsync(ResetPasswordForUserView model, IdentityUser user, + DepartmentMember member) + { + model.Name = await UserHelper.GetFullNameForUser(user.Id); + model.Email = user.Email; + model.Username = user.UserName; + model.MinPasswordLength = await _departmentSsoService.GetEffectiveMinPasswordLengthAsync(DepartmentId); + model.IsSsoManaged = await IsSsoManagedAsync(user.Id, member); + model.UseEmailResetLink = await _departmentSettingsService.GetRequirePasswordResetViaEmailAsync(DepartmentId); + } + + private async Task CanAdministratorResetPasswordAsync(string targetUserId, DepartmentMember targetMember) + { + if (string.IsNullOrWhiteSpace(targetUserId) || targetUserId == UserId || targetMember == null) + return false; + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + if (department == null || targetUserId == department.ManagingUserId) + return false; + + if (department.IsUserAnAdmin(UserId)) + return true; + + // Group administrators may manage only non-department-admin users in the group they + // administer. Target group membership is resolved server-side on every request. + if (department.IsUserAnAdmin(targetUserId)) + return false; + + var targetGroup = await _departmentGroupsService.GetGroupForUserAsync(targetUserId, DepartmentId); + return targetGroup != null && targetGroup.IsUserGroupAdmin(UserId); + } + + private async Task SendAdministratorPasswordResetLinkAsync(ResetPasswordForUserView model, + IdentityUser user, CancellationToken cancellationToken) + { + var requestedOn = DateTime.UtcNow; + var ipAddress = IpAddressHelper.GetRequestIP(Request, true); + var issued = false; + var emailSent = false; + string issuedToken = null; + + try + { + var issue = await _passwordRecoveryService.IssueAsync(user.Id, user.Email, ipAddress, + user.AuthenticationGeneration, user.SecurityStamp, cancellationToken); + issued = issue.Issued && !issue.RateLimited; + issuedToken = issue.Token; + + if (issued) + { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var profile = await _userProfileService.GetProfileByUserIdAsync(user.Id); + var resetPageUrl = Url.Action("ResetPassword", "Account", new { area = "" }, Request.Scheme); + if (string.IsNullOrWhiteSpace(resetPageUrl)) + throw new InvalidOperationException("The password reset URL could not be generated."); + + var resetUrl = $"{resetPageUrl}#token={Uri.EscapeDataString(issue.Token)}"; + emailSent = await _emailService.SendPasswordRecoveryEmail(user.Email, + profile?.FullName.AsFirstNameLastName ?? model.Name ?? "Resgrid user", + department?.Name ?? "Resgrid", resetUrl, ipAddress, + BoundSecurityContext(Request.Headers.UserAgent.ToString(), 512), requestedOn, false); + + if (!emailSent) + await TryRemovePasswordRecoveryTokenAsync(issue.Token, cancellationToken); + } + } + catch (Exception ex) + { + Logging.LogException(ex, "Administrator password reset link processing failed."); + if (!string.IsNullOrWhiteSpace(issuedToken)) + await TryRemovePasswordRecoveryTokenAsync(issuedToken, cancellationToken); + } + + await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Website, + Type = (int)SystemAuditTypes.PasswordResetLinkSentByAdministrator, + DepartmentId = DepartmentId, + UserId = UserId, + TargetUserId = user.Id, + Username = User.Identity?.Name, + IpAddress = ipAddress, + CorrelationId = HttpContext.TraceIdentifier, + ServerName = Environment.MachineName, + Successful = issued && emailSent, + Data = issued && emailSent + ? "Administrator sent a short-lived, single-use password reset link. Existing sessions remain valid until the user completes the reset." + : "Administrator password reset link request was denied by rate limiting or could not be delivered." + }, cancellationToken); + + if (!issued || !emailSent) + { + ModelState.AddModelError(string.Empty, + "The password reset email could not be sent. Wait before trying again or verify the user's email configuration."); + return View(model); + } + + TempData["PasswordResetMessage"] = + "A short-lived, single-use password reset link was sent to the user's confirmed email address."; + return RedirectToAction(nameof(ResetPasswordForUser), new { userId = user.Id }); + } + + private async Task TryRemovePasswordRecoveryTokenAsync(string token, CancellationToken cancellationToken) + { + try + { + await _passwordRecoveryService.RemoveAsync(token, cancellationToken); + } + catch (Exception ex) + { + Logging.LogException(ex, "Unable to remove an undelivered administrator password recovery token."); + } + } + + private static string BoundSecurityContext(string value, int maximumLength) + { + if (string.IsNullOrWhiteSpace(value)) + return "unknown"; + + var normalized = value.Replace('\r', ' ').Replace('\n', ' ').Trim(); + return normalized.Length <= maximumLength ? normalized : normalized.Substring(0, maximumLength); + } + + private async Task IsSsoManagedAsync(string userId, DepartmentMember member = null) + { + var state = await _externalIdentityLinkService.GetSsoManagementStateAsync(userId); + if (state.IsSsoManaged) + return true; + + member ??= await _departmentsService.GetDepartmentMemberAsync(userId, DepartmentId); + return member != null && (!string.IsNullOrWhiteSpace(member.ExternalSsoId) || member.SsoLinkedOn.HasValue); + } + #region Your Departments [HttpGet] [Authorize(Policy = ResgridResources.Personnel_View)] @@ -1065,6 +1262,7 @@ public async Task JoinDepartment(int id, string code) [HttpPost] [Authorize(Policy = ResgridResources.Personnel_View)] + [ValidateAntiForgeryToken] public async Task JoinDepartment(IFormCollection form) { var departmentId = form["deparmentId"]; @@ -1086,27 +1284,20 @@ public async Task JoinDepartment(IFormCollection form) [HttpPost] [Authorize(Policy = ResgridResources.Personnel_View)] - public async Task SetActiveDepartment([FromBody]ChangeActiveDepartmentModel model) + [ValidateAntiForgeryToken] + public async Task SetActiveDepartment([FromBody]ChangeActiveDepartmentModel model, + CancellationToken cancellationToken) { if (await _departmentsService.IsMemberOfDepartmentAsync(model.DepartmentId, UserId)) { - var user = await _userManager.FindByIdAsync(UserId); - - await _departmentsService.SetActiveDepartmentForUserAsync(UserId, model.DepartmentId, user); - - await _signInManager.SignOutAsync(); - - - await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + if (!await CanContinueCurrentAuthenticationInDepartmentAsync(model.DepartmentId, cancellationToken)) + return Forbid(); - await _signInManager.SignInAsync(user, true); - - await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, HttpContext.User, new AuthenticationProperties - { - ExpiresUtc = DateTime.UtcNow.AddHours(4), - IsPersistent = false, - AllowRefresh = false - }); + var user = await _userManager.FindByIdAsync(UserId); + await _departmentsService.SetActiveDepartmentForUserAsync(UserId, model.DepartmentId, user, + cancellationToken); + if (!await RenewPrincipalForDepartmentAsync(user, model.DepartmentId, cancellationToken)) + return Unauthorized(); return RedirectToAction("Dashboard", "Home", new { Area = "User" }); } @@ -1114,29 +1305,23 @@ public async Task SetActiveDepartment([FromBody]ChangeActiveDepa return RedirectToAction("YourDepartments"); } - [HttpGet] + [HttpPost] [Authorize(Policy = ResgridResources.Personnel_View)] + [ValidateAntiForgeryToken] public async Task SetDefaultDepartment(int departmentId, CancellationToken cancellationToken) { if (await _departmentsService.IsMemberOfDepartmentAsync(departmentId, UserId)) { + if (!await CanContinueCurrentAuthenticationInDepartmentAsync(departmentId, cancellationToken)) + return Forbid(); + var user = await _userManager.FindByIdAsync(UserId); await _departmentsService.SetActiveDepartmentForUserAsync(UserId, departmentId, user, cancellationToken); await _departmentsService.SetDefaultDepartmentForUserAsync(UserId, departmentId, user, cancellationToken); - await _signInManager.SignOutAsync(); - - await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); - - await _signInManager.SignInAsync(user, true); - - await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, HttpContext.User, new AuthenticationProperties - { - ExpiresUtc = DateTime.UtcNow.AddHours(4), - IsPersistent = false, - AllowRefresh = false - }); + if (!await RenewPrincipalForDepartmentAsync(user, departmentId, cancellationToken)) + return Unauthorized(); return RedirectToAction("Dashboard", "Home", new { Area = "User" }); } @@ -1144,112 +1329,107 @@ public async Task SetDefaultDepartment(int departmentId, Cancell return RedirectToAction("YourDepartments"); } - [HttpGet] + [HttpPost] [Authorize(Policy = ResgridResources.Personnel_View)] + [ValidateAntiForgeryToken] public async Task DeleteDepartmentLink(int departmentId, CancellationToken cancellationToken) { - if (await _departmentsService.IsMemberOfDepartmentAsync(departmentId, UserId)) - { - var departmentLinks= await _departmentsService.GetAllDepartmentsForUserAsync(UserId); - var departmentToRemove = departmentLinks.FirstOrDefault(x => x.DepartmentId == departmentId); - var user = await _userManager.FindByIdAsync(UserId); - - if (departmentToRemove != null && departmentLinks.Count > 1) - { - var defaultDepartment = departmentLinks.FirstOrDefault(x => x.IsDefault); - - if (departmentToRemove.IsActive) - { - if (defaultDepartment != null && - departmentToRemove.DepartmentId != defaultDepartment.DepartmentId) - { - await _departmentsService.SetActiveDepartmentForUserAsync(UserId, defaultDepartment.DepartmentId, - user, cancellationToken); - var revoked = await _deleteService.RevokeDepartmentAccessAsync(UserId, departmentToRemove.DepartmentId, UserId, cancellationToken); - - if (!revoked) - { - Resgrid.Framework.Logging.LogError($"DeleteDepartmentLink: failed to revoke department {departmentToRemove.DepartmentId} access for user {UserId}"); - return RedirectToAction("YourDepartments"); - } - - await _signInManager.SignOutAsync(); - - await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); - - await _signInManager.SignInAsync(user, true); - - await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, - HttpContext.User, new AuthenticationProperties - { - ExpiresUtc = DateTime.UtcNow.AddHours(4), - IsPersistent = false, - AllowRefresh = false - }); - - return RedirectToAction("Dashboard", "Home", new {Area = "User"}); - } - else if (defaultDepartment != null && - departmentToRemove.DepartmentId == defaultDepartment.DepartmentId) - { - var nextDepartmentUp = - departmentLinks.FirstOrDefault(x => x.DepartmentId != departmentToRemove.DepartmentId); - - if (nextDepartmentUp != null) - { - await _departmentsService.SetActiveDepartmentForUserAsync(UserId, nextDepartmentUp.DepartmentId, - user, cancellationToken); - var revoked = await _deleteService.RevokeDepartmentAccessAsync(UserId, departmentToRemove.DepartmentId, UserId, cancellationToken); + if (!await _departmentsService.IsMemberOfDepartmentAsync(departmentId, UserId)) + return RedirectToAction("YourDepartments"); - if (!revoked) - { - Resgrid.Framework.Logging.LogError($"DeleteDepartmentLink: failed to revoke department {departmentToRemove.DepartmentId} access for user {UserId}"); - return RedirectToAction("YourDepartments"); - } + var departmentLinks = await _departmentsService.GetAllDepartmentsForUserAsync(UserId); + var departmentToRemove = departmentLinks.FirstOrDefault(x => x.DepartmentId == departmentId); + if (departmentToRemove == null || departmentLinks.Count <= 1 || + departmentToRemove.Department?.ManagingUserId == UserId) + return RedirectToAction("YourDepartments"); - await _signInManager.SignOutAsync(); + var user = await _userManager.FindByIdAsync(UserId); + var remainingDepartment = departmentLinks + .Where(x => x.DepartmentId != departmentToRemove.DepartmentId) + .OrderByDescending(x => x.IsDefault) + .FirstOrDefault(); + var switchesDepartment = departmentToRemove.IsActive; + var canKeepCurrentSession = !switchesDepartment || + await CanContinueCurrentAuthenticationInDepartmentAsync(remainingDepartment.DepartmentId, + cancellationToken); + + if (switchesDepartment) + { + await _departmentsService.SetActiveDepartmentForUserAsync(UserId, remainingDepartment.DepartmentId, + user, cancellationToken); + if (canKeepCurrentSession && + !await RenewPrincipalForDepartmentAsync(user, remainingDepartment.DepartmentId, cancellationToken)) + return Unauthorized(); + } - await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + if (departmentToRemove.IsDefault) + await _departmentsService.SetDefaultDepartmentForUserAsync(UserId, remainingDepartment.DepartmentId, + user, cancellationToken); - await _signInManager.SignInAsync(user, true); + var revoked = await _deleteService.RevokeDepartmentAccessAsync(UserId, + departmentToRemove.DepartmentId, UserId, cancellationToken); + if (!revoked) + { + Resgrid.Framework.Logging.LogError($"DeleteDepartmentLink: failed to revoke department {departmentToRemove.DepartmentId} access for user {UserId}"); + return RedirectToAction("YourDepartments"); + } - await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, - HttpContext.User, new AuthenticationProperties - { - ExpiresUtc = DateTime.UtcNow.AddHours(4), - IsPersistent = false, - AllowRefresh = false - }); + if (switchesDepartment && !canKeepCurrentSession) + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return RedirectToAction("LogOn", "Account", new {Area = ""}); + } - return RedirectToAction("Dashboard", "Home", new {Area = "User"}); - } - } - } - else if (defaultDepartment != null) - { - if (departmentToRemove.DepartmentId != defaultDepartment.DepartmentId) - { - var revoked = await _deleteService.RevokeDepartmentAccessAsync(UserId, departmentToRemove.DepartmentId, UserId, cancellationToken); + return switchesDepartment + ? RedirectToAction("Dashboard", "Home", new {Area = "User"}) + : RedirectToAction("YourDepartments"); + } - if (!revoked) - Resgrid.Framework.Logging.LogError($"DeleteDepartmentLink: failed to revoke department {departmentToRemove.DepartmentId} access for user {UserId}"); - } - else - { - var nextDepartmentUp = - departmentLinks.FirstOrDefault(x => x.DepartmentId != departmentToRemove.DepartmentId); - - if (nextDepartmentUp != null) - { - await _departmentsService.SetDefaultDepartmentForUserAsync(UserId, nextDepartmentUp.DepartmentId, - user, cancellationToken); - } - } - } - } - } + private async Task CanContinueCurrentAuthenticationInDepartmentAsync(int departmentId, + CancellationToken cancellationToken) + { + var requiresSso = await _departmentSsoService.IsRequireSsoPolicyActiveAsync(departmentId, + cancellationToken) && await _departmentSsoService.IsSsoEnabledForDepartmentAsync(departmentId, + cancellationToken); + var localLoginAllowed = await _externalIdentityLinkService.IsLocalLoginAllowedAsync(UserId, + departmentId, cancellationToken); + if (!requiresSso && localLoginAllowed) + return true; + + // An authentication established for another department cannot be used to enter a + // department that requires its own SSO policy. The user must authenticate at that IdP. + var currentSessionId = User.FindFirstValue(SessionClaimTypes.SessionId); + var current = (await _userSessionService.GetActiveForUserAsync(UserId, cancellationToken)) + .FirstOrDefault(session => session.UserSessionId == currentSessionId); + return current?.DepartmentId == departmentId && + (current.AuthenticationMethod == UserSessionAuthenticationMethod.OidcSso || + current.AuthenticationMethod == UserSessionAuthenticationMethod.SamlSso); + } - return RedirectToAction("YourDepartments"); + private async Task RenewPrincipalForDepartmentAsync(IdentityUser user, int departmentId, + CancellationToken cancellationToken) + { + var authentication = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + var currentSessionId = User.FindFirstValue(SessionClaimTypes.SessionId); + if (!string.IsNullOrWhiteSpace(currentSessionId) && + !await _userSessionService.MoveSessionToDepartmentAsync(UserId, currentSessionId, departmentId, + cancellationToken)) + return false; + + var principal = await _signInManager.CreateUserPrincipalAsync(user); + if (!string.IsNullOrWhiteSpace(currentSessionId) && principal.Identity is ClaimsIdentity identity) + identity.AddClaim(new Claim(SessionClaimTypes.SessionId, currentSessionId)); + + var properties = authentication.Properties ?? new AuthenticationProperties + { + IssuedUtc = DateTimeOffset.UtcNow, + ExpiresUtc = DateTimeOffset.UtcNow.AddHours(4), + IsPersistent = false, + AllowRefresh = false + }; + await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, properties); + HttpContext.User = principal; + return true; } #endregion Your Departments diff --git a/Web/Resgrid.Web/Areas/User/Models/DepartmentSettingsModel.cs b/Web/Resgrid.Web/Areas/User/Models/DepartmentSettingsModel.cs index 9f1953902..f3584b23c 100644 --- a/Web/Resgrid.Web/Areas/User/Models/DepartmentSettingsModel.cs +++ b/Web/Resgrid.Web/Areas/User/Models/DepartmentSettingsModel.cs @@ -79,6 +79,7 @@ public class DepartmentSettingsModel : BaseUserModel public SelectList CallSortTypes { get; set; } public bool EnableModernNotifications { get; set; } + public bool RequirePasswordResetViaEmail { get; set; } [Display(Name = "Require security PIN for dangerous chatbot/text actions")] public bool ForceChatbotSecurityPin { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/EditProfileModel.cs b/Web/Resgrid.Web/Areas/User/Models/EditProfileModel.cs index 34c98245d..adf7bb264 100644 --- a/Web/Resgrid.Web/Areas/User/Models/EditProfileModel.cs +++ b/Web/Resgrid.Web/Areas/User/Models/EditProfileModel.cs @@ -27,6 +27,11 @@ public class EditProfileModel: BaseUserModel public List UsersRoles { get; set; } public bool IsOwnProfile { get; set; } public bool IsFreePlan { get; set; } + public bool CanManageLocalCredentials { get; set; } + public bool CanResetPassword { get; set; } + public bool IsSsoManaged { get; set; } + public bool IsEmailExternallyManaged { get; set; } + public bool RequirePasswordResetViaEmail { get; set; } [Required] [MaxLength(50)] @@ -46,31 +51,10 @@ public class EditProfileModel: BaseUserModel [Display(Name = "Is Department Admin")] public bool IsDepartmentAdmin { get; set; } - [Display(Name = "New Username")] - public string NewUsername { get; set; } - - [StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 6)] - [DataType(DataType.Password)] - [Display(Name = "New password")] - public string NewPassword { get; set; } - - [DataType(DataType.Password)] - [Display(Name = "Confirm new password")] - [System.ComponentModel.DataAnnotations.Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] - public string ConfirmPassword { get; set; } - - [DataType(DataType.Password)] - [Display(Name = "Current password")] - public string OldPassword { get; set; } - public bool IsDisabled { get; set; } public bool IsHidden { get; set; } public bool AreYouSure { get; set; } - /// Effective minimum password length for the department (≥ 8). Shown as a hint on password-change fields. - public int MinPasswordLength { get; set; } = 8; - - [StringLength(500, ErrorMessage = "Street address cannot exceed 500 characters.")] public string PhysicalAddress1 { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Personnel/PersonnelForJson.cs b/Web/Resgrid.Web/Areas/User/Models/Personnel/PersonnelForJson.cs index bb820023c..b80525055 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Personnel/PersonnelForJson.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Personnel/PersonnelForJson.cs @@ -33,6 +33,8 @@ public class PersonnelForListJson public string UserId { get; set; } public bool CanRemoveUser { get; set; } public bool CanEditUser { get; set; } + public bool CanResetPassword { get; set; } + public bool IsSsoManaged { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public int GroupId { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/PersonnelModel.cs b/Web/Resgrid.Web/Areas/User/Models/PersonnelModel.cs index 7bda37ad2..1c652992e 100644 --- a/Web/Resgrid.Web/Areas/User/Models/PersonnelModel.cs +++ b/Web/Resgrid.Web/Areas/User/Models/PersonnelModel.cs @@ -8,6 +8,7 @@ namespace Resgrid.Web.Areas.User.Models { public class PersonnelModel: BaseUserModel { + public bool RequirePasswordResetViaEmail { get; set; } public Department Department { get; set; } public IdentityUser User { get; set; } public List Users { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Profile/ResetPasswordForUserView.cs b/Web/Resgrid.Web/Areas/User/Models/Profile/ResetPasswordForUserView.cs index 8512907e8..3abba1828 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Profile/ResetPasswordForUserView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Profile/ResetPasswordForUserView.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel.DataAnnotations; +using Resgrid.Framework; namespace Resgrid.Web.Areas.User.Models.Profile { @@ -11,12 +12,15 @@ public class ResetPasswordForUserView public string Username { get; set; } public string UserId { get; set; } public bool EmailUser { get; set; } + public bool IsSsoManaged { get; set; } + public bool UseEmailResetLink { get; set; } /// Effective minimum password length from the department policy (≥ 8). Shown as a hint in the view. public int MinPasswordLength { get; set; } = 8; [Required] [StringLength(100, ErrorMessage = "The {0} must be at least 8 characters long.", MinimumLength = 8)] + [PasswordComplexity(MinLength = 8, RequireUppercase = true, RequireLowercase = true, RequireDigit = true, RequireSpecialChar = false)] [DataType(DataType.Password)] [Display(Name = "Password")] public string Password { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Security/AccountCredentialViews.cs b/Web/Resgrid.Web/Areas/User/Models/Security/AccountCredentialViews.cs new file mode 100644 index 000000000..279a56b4d --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Security/AccountCredentialViews.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using Resgrid.Framework; + +namespace Resgrid.Web.Areas.User.Models.Security +{ + public class ChangeUsernameView + { + public string CurrentUsername { get; set; } + public bool IsSsoManaged { get; set; } + + [Required, MaxLength(256)] + [Display(Name = "New username")] + public string NewUsername { get; set; } + + [Required, DataType(DataType.Password)] + [Display(Name = "Current password")] + public string CurrentPassword { get; set; } + } + + public class ChangePasswordView + { + public bool IsSsoManaged { get; set; } + public int MinPasswordLength { get; set; } = 8; + + [Required, DataType(DataType.Password)] + [Display(Name = "Current password")] + public string CurrentPassword { get; set; } + + [Required] + [StringLength(100, MinimumLength = 8)] + [PasswordComplexity(MinLength = 8, RequireUppercase = true, RequireLowercase = true, RequireDigit = true, RequireSpecialChar = false)] + [DataType(DataType.Password)] + [Display(Name = "New password")] + public string NewPassword { get; set; } + + [Required, DataType(DataType.Password)] + [Compare(nameof(NewPassword), ErrorMessage = "The new password and confirmation password do not match.")] + [Display(Name = "Confirm new password")] + public string ConfirmPassword { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Security/ActiveSessionsView.cs b/Web/Resgrid.Web/Areas/User/Models/Security/ActiveSessionsView.cs new file mode 100644 index 000000000..a02842f9e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Security/ActiveSessionsView.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using Resgrid.Model.Security; + +namespace Resgrid.Web.Areas.User.Models.Security +{ + public class ActiveSessionsView + { + public string CurrentSessionId { get; set; } + public IReadOnlyList Sessions { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/ChangePassword.cshtml b/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/ChangePassword.cshtml new file mode 100644 index 000000000..f4b81fcfb --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/ChangePassword.cshtml @@ -0,0 +1,26 @@ +@model Resgrid.Web.Areas.User.Models.Security.ChangePasswordView +@{ + ViewBag.Title = "Change Password"; + Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; +} +@section Styles { } +

Change Password

+
+
Important: Changing your password signs you out of every Resgrid app and device and revokes all existing access and refresh tokens.
+ @if (Model.IsSsoManaged) + { +
This account is managed by SSO. Change the password with your identity provider or contact your administrator.
+ } +
+ @Html.AntiForgeryToken() +
+
+
Password requirements:
  • At least @Model.MinPasswordLength characters
  • At least one number
  • Uppercase and lowercase letters
+
+
Cancel
+
+
+@section Scripts { + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/ChangeUsername.cshtml b/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/ChangeUsername.cshtml new file mode 100644 index 000000000..535e6ed30 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/ChangeUsername.cshtml @@ -0,0 +1,21 @@ +@model Resgrid.Web.Areas.User.Models.Security.ChangeUsernameView +@{ + ViewBag.Title = "Change Username"; + Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; +} +

Change Username

+
+
Important: Changing your username signs you out of every Resgrid app and device and revokes all existing access and refresh tokens.
+ @if (Model.IsSsoManaged) + { +
This account is managed by SSO. Change the username with your identity provider or contact your administrator.
+ } +
+ @Html.AntiForgeryToken() +
+

@Model.CurrentUsername

+
+
+
Cancel
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/Sessions.cshtml b/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/Sessions.cshtml new file mode 100644 index 000000000..3bc44ada6 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/Sessions.cshtml @@ -0,0 +1,85 @@ +@model Resgrid.Web.Areas.User.Models.Security.ActiveSessionsView +@using Resgrid.Model +@{ + ViewBag.Title = "Active Sessions"; + Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; +} + +
+
+

Active Sessions

+ +
+
+ +
+
+
+
+
Where your account is signed in
+
+

Review the application, device, approximate network location, and last authenticated activity. Last active is not an online indicator and may lag by about five minutes. If you do not recognize a session, revoke it immediately and change your password.

+ @if (TempData["SessionMessage"] != null) + { +
@TempData["SessionMessage"]
+ } + +
+ + + + @foreach (var session in Model.Sessions) + { + var current = string.Equals(session.UserSessionId, Model.CurrentSessionId, StringComparison.Ordinal); + + + + + + + + } + +
Application / deviceLast activeNetwork locationStarted / expires
+ @session.ClientApplication + @if (current) { This session } +
+ @(session.DeviceName ?? session.DeviceType ?? "Unknown device") +
Signed in with @session.AuthenticationMethod
+ @if (!string.IsNullOrWhiteSpace(session.ApplicationVersion)) + {
App version @session.ApplicationVersion
} + @if (!string.IsNullOrWhiteSpace(session.OperatingSystem) || !string.IsNullOrWhiteSpace(session.Browser)) + {
@session.OperatingSystem @session.Browser
} + @if (session.IsLegacyAdopted) + {
Existing session — some device details were unavailable.
} + else if (!string.IsNullOrWhiteSpace(session.UserAgent)) + {
@session.UserAgent
} +
@session.LastActiveOn.ToUniversalTime().ToString("u")
UTC
+ @(session.LastIpAddress ?? "Unknown IP") + @if (!string.IsNullOrWhiteSpace(session.LastCity) || !string.IsNullOrWhiteSpace(session.LastRegion) || !string.IsNullOrWhiteSpace(session.LastCountry)) + {
Approx. @string.Join(", ", new[] { session.LastCity, session.LastRegion, session.LastCountry }.Where(x => !string.IsNullOrWhiteSpace(x)))
} +
@session.CreatedOn.ToUniversalTime().ToString("u")
Expires @session.ExpiresOn.ToUniversalTime().ToString("u") UTC
+
+ @Html.AntiForgeryToken() + + +
+
+
+ +
+ @Html.AntiForgeryToken() + +
+
+ @Html.AntiForgeryToken() + +
+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml index ed1ceee6a..7612abcbe 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml @@ -96,6 +96,17 @@ @localizer["EnableModernNotificationsHelp"] +
+ +
+
+
+ +
+
+ @localizer["RequirePasswordResetViaEmailHelp"] +
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml b/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml index fd369cb52..b6f16e050 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml @@ -34,10 +34,17 @@ @if (Model.IsOwnProfile) { @localizer["ReportDelivery"] + Active Sessions + @if (Model.CanManageLocalCredentials) + { + Change Username + Change Password + } } - else + else if (Model.CanResetPassword && !Model.IsSsoManaged) { - @localizer["ChangeUserPassword"] + var passwordActionLabel = Model.RequirePasswordResetViaEmail ? "Send Password Reset Email" : localizer["ChangeUserPassword"].Value; + @passwordActionLabel } @localizer["StaffingSchedule"] @localizer["Certifications"] @@ -94,7 +101,11 @@
- + + @if (Model.IsEmailExternallyManaged) + { + This email address is managed by the linked SSO provider. + } @if (!string.IsNullOrWhiteSpace(Model.Email)) { @if (Model.EmailVerified == true) @@ -443,42 +454,18 @@

@localizer["AccountInfoHeader"]

-
- -
- - @localizer["NewUsernameHelp"] -
-
-
- -
- - @localizer["NewPasswordHelp"] -
- @localizer["PasswordRequirementsHeader"]: -
    -
  • @string.Format(localizer["PasswordRequirementsLength"], Model.MinPasswordLength)
  • -
  • @localizer["PasswordRequirementsDigit"]
  • -
  • @localizer["PasswordRequirementsCase"]
  • -
-
-
-
-
- -
- - @localizer["ConfirmPasswordHelp"] -
-
-
- -
- - @localizer["CurrentPasswordHelp"] -
-
+ @if (Model.CanManageLocalCredentials) + { + + } + else + { +
Username and password are managed by your SSO provider. Use Active Sessions to review or revoke signed-in devices.
+ } } @if (!string.IsNullOrEmpty(Model.UdfFormHtml)) diff --git a/Web/Resgrid.Web/Areas/User/Views/Personnel/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Personnel/Index.cshtml index 08f5d7c0c..7459fd914 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Personnel/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Personnel/Index.cshtml @@ -494,7 +494,13 @@ @Html.Raw("Toggle Dropdown") @Html.Raw("") @Html.Raw("") @Html.Raw("
 ") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Profile/ResetPasswordForUser.cshtml b/Web/Resgrid.Web/Areas/User/Views/Profile/ResetPasswordForUser.cshtml index 58a94f22f..b444d52eb 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Profile/ResetPasswordForUser.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Profile/ResetPasswordForUser.cshtml @@ -1,11 +1,14 @@ @model Resgrid.Web.Areas.User.Models.Profile.ResetPasswordForUserView @inject IStringLocalizer localizer @{ - ViewBag.Title = "Resgrid | " + @localizer["ChangePasswordHeader"]; + var pageTitle = Model.UseEmailResetLink ? localizer["SendPasswordResetEmailHeader"] : localizer["ChangePasswordHeader"]; + ViewBag.Title = "Resgrid | " + pageTitle; } @section Styles { +@if (!Model.UseEmailResetLink) +{