diff --git a/Core/Resgrid.Config/TelemetryConfig.cs b/Core/Resgrid.Config/TelemetryConfig.cs index 7e4cf0dc..df61da32 100644 --- a/Core/Resgrid.Config/TelemetryConfig.cs +++ b/Core/Resgrid.Config/TelemetryConfig.cs @@ -16,6 +16,9 @@ public static class TelemetryConfig public static string AptabaseBigBoardApiKey = ""; public static string AptabaseDispatchApiKey = ""; + public static string CountlyUrl = ""; + public static string CountlyWebKey = ""; + public static string GetAnalyticsKey() { if (ExporterType == TelemetryExporters.PostHog) diff --git a/Core/Resgrid.Framework/PasswordComplexityAttribute.cs b/Core/Resgrid.Framework/PasswordComplexityAttribute.cs new file mode 100644 index 00000000..031174e3 --- /dev/null +++ b/Core/Resgrid.Framework/PasswordComplexityAttribute.cs @@ -0,0 +1,66 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; + +namespace Resgrid.Framework +{ + /// + /// Validation attribute for password complexity requirements + /// + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] + public sealed class PasswordComplexityAttribute : ValidationAttribute + { + public int MinLength { get; set; } = 8; + public bool RequireUppercase { get; set; } = true; + public bool RequireLowercase { get; set; } = true; + public bool RequireDigit { get; set; } = true; + public bool RequireSpecialChar { get; set; } = false; + + public PasswordComplexityAttribute() + { + ErrorMessage = "Password does not meet complexity requirements"; + } + + public override bool IsValid(object value) + { + if (value is not string password) + { + return false; + } + + var result = StringHelpers.VerifyPasswordComplexity( + password, + MinLength, + RequireUppercase, + RequireLowercase, + RequireDigit, + RequireSpecialChar); + + return result.IsValid; + } + + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + if (value is not string password) + { + return new ValidationResult("Invalid password format"); + } + + var result = StringHelpers.VerifyPasswordComplexity( + password, + MinLength, + RequireUppercase, + RequireLowercase, + RequireDigit, + RequireSpecialChar); + + if (result.IsValid) + { + return ValidationResult.Success; + } + + var errorMessage = string.Join(", ", result.Errors); + return new ValidationResult(errorMessage, new[] { validationContext.MemberName }); + } + } +} diff --git a/Core/Resgrid.Framework/StringHelpers.cs b/Core/Resgrid.Framework/StringHelpers.cs index 62626a2b..872be8ce 100644 --- a/Core/Resgrid.Framework/StringHelpers.cs +++ b/Core/Resgrid.Framework/StringHelpers.cs @@ -12,6 +12,13 @@ namespace Resgrid.Framework { + /// + /// Result of password complexity verification + /// + public sealed record PasswordComplexityResult( + bool IsValid, + List Errors); + public static class StringHelpers { /// @@ -247,5 +254,79 @@ public static string SanitizeCoordinatesString(string source) return resultStrBuilder.ToString(); } + /// + /// Verifies password complexity against security requirements + /// + /// The password to verify + /// Minimum password length (default: 8) + /// Require at least one uppercase letter (default: true) + /// Require at least one lowercase letter (default: true) + /// Require at least one digit (default: true) + /// Require at least one special character (default: false) + /// PasswordComplexityResult indicating validity and any errors + public static PasswordComplexityResult VerifyPasswordComplexity( + string password, + int minLength = 8, + bool requireUppercase = true, + bool requireLowercase = true, + bool requireDigit = true, + bool requireSpecialChar = false) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(password)) + { + errors.Add("Password cannot be empty"); + return new PasswordComplexityResult(false, errors); + } + + // Check minimum length + if (password.Length < minLength) + { + errors.Add($"Password must be at least {minLength} characters long"); + } + + // Check for uppercase letter + if (requireUppercase && !password.Any(char.IsUpper)) + { + errors.Add("Password must include an uppercase letter"); + } + + // Check for lowercase letter + if (requireLowercase && !password.Any(char.IsLower)) + { + errors.Add("Password must include a lowercase letter"); + } + + // Check for digit + if (requireDigit && !password.Any(char.IsDigit)) + { + errors.Add("Password must include a number (digit)"); + } + + // Check for special character + if (requireSpecialChar) + { + var specialChars = "!@#$%^&*()_+-=[]{}|;:,.<>?"; + if (!password.Any(c => specialChars.Contains(c))) + { + errors.Add("Password must include a special character"); + } + } + + return new PasswordComplexityResult(errors.Count == 0, errors); + } + + /// + /// Validates password complexity using default Resgrid requirements + /// + /// The password to validate + /// True if password meets complexity requirements + public static bool IsValidPassword(string password) + { + var result = VerifyPasswordComplexity(password); + return result.IsValid; + } + } } diff --git a/Core/Resgrid.Services/PushService.cs b/Core/Resgrid.Services/PushService.cs index 18107d0b..a64bd17d 100644 --- a/Core/Resgrid.Services/PushService.cs +++ b/Core/Resgrid.Services/PushService.cs @@ -35,7 +35,7 @@ public PushService(IPushLogsService pushLogsService, INotificationProvider notif public async Task Register(PushUri pushUri) { - if (pushUri == null || String.IsNullOrWhiteSpace(pushUri.DeviceId) || string.IsNullOrWhiteSpace(pushUri.PushLocation)) + if (pushUri == null || string.IsNullOrWhiteSpace(pushUri.DeviceId) || string.IsNullOrWhiteSpace(pushUri.PushLocation)) return false; var code = pushUri.PushLocation; @@ -61,7 +61,7 @@ public async Task UnRegister(PushUri pushUri) public async Task RegisterUnit(PushUri pushUri) { - if (pushUri == null || !pushUri.UnitId.HasValue || string.IsNullOrWhiteSpace(pushUri.PushLocation)) + if (pushUri == null || !pushUri.UnitId.HasValue || string.IsNullOrWhiteSpace(pushUri.DeviceId) || string.IsNullOrWhiteSpace(pushUri.PushLocation)) return false; var unitId = pushUri.UnitId.Value; diff --git a/Tests/Resgrid.Tests/Framework/PasswordComplexityExamples.cs b/Tests/Resgrid.Tests/Framework/PasswordComplexityExamples.cs new file mode 100644 index 00000000..9255e9a0 --- /dev/null +++ b/Tests/Resgrid.Tests/Framework/PasswordComplexityExamples.cs @@ -0,0 +1,90 @@ +using System; +using System.Linq; +using Resgrid.Framework; + +namespace Resgrid.Tests.Framework +{ + /// + /// Example usage and tests for password complexity verification + /// + public static class PasswordComplexityExamples + { + /// + /// Demonstrates how to use the password complexity verification + /// + public static void RunExamples() + { + // Test cases + var testPasswords = new[] + { + "abc123", // Too short, no uppercase + "ABC123", // No lowercase + "abcDEF", // No digits + "Abc123", // Valid according to default requirements + "Password123", // Valid + "MyPassword1", // Valid + "", // Empty + "Abc123!@#" // Valid with special chars + }; + + Console.WriteLine("Password Complexity Verification Examples:"); + Console.WriteLine("========================================="); + + foreach (var password in testPasswords) + { + var result = StringHelpers.VerifyPasswordComplexity(password); + Console.WriteLine($"Password: '{password}'"); + Console.WriteLine($"Valid: {result.IsValid}"); + if (!result.IsValid) + { + Console.WriteLine($"Errors: {string.Join(", ", result.Errors)}"); + } + Console.WriteLine(); + } + + // Test with custom requirements matching current RegisterViewModel + Console.WriteLine("Resgrid Default Requirements:"); + Console.WriteLine("============================"); + + var resgridDefaults = new[] + { + "abc123", // Should fail + "Password1", // Should pass + "MySecurePass123" // Should pass + }; + + foreach (var password in resgridDefaults) + { + var result = StringHelpers.VerifyPasswordComplexity( + password, + minLength: 8, + requireUppercase: true, + requireLowercase: true, + requireDigit: true, + requireSpecialChar: false); + + Console.WriteLine($"Password: '{password}'"); + Console.WriteLine($"Valid: {result.IsValid}"); + if (!result.IsValid) + { + Console.WriteLine($"Errors: {string.Join(", ", result.Errors)}"); + } + Console.WriteLine(); + } + } + + /// + /// Simple validation method for quick checks using Resgrid defaults + /// + public static bool ValidatePasswordForResgrid(string password) + { + return StringHelpers.VerifyPasswordComplexity( + password, + minLength: 8, + requireUppercase: true, + requireLowercase: true, + requireDigit: true, + requireSpecialChar: false).IsValid; + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/AddPersonModel.cs b/Web/Resgrid.Web/Areas/User/Models/AddPersonModel.cs index 3a6c1b6b..02657046 100644 --- a/Web/Resgrid.Web/Areas/User/Models/AddPersonModel.cs +++ b/Web/Resgrid.Web/Areas/User/Models/AddPersonModel.cs @@ -1,9 +1,10 @@ -using System; +using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Identity; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using Microsoft.AspNetCore.Mvc.Rendering; -using Resgrid.Model.Identity; -using Resgrid.Model; namespace Resgrid.Web.Areas.User.Models { @@ -42,7 +43,13 @@ public class AddPersonModel: BaseUserModel [DataType(DataType.EmailAddress)] public string Email { get; set; } - [StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 6)] + [StringLength(100, ErrorMessage = "The password 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")] [Required] diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml index 07ccffcf..a73bec22 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml @@ -58,6 +58,19 @@ }) } + + @if (!String.IsNullOrWhiteSpace(Resgrid.Config.TelemetryConfig.CountlyUrl) && !String.IsNullOrWhiteSpace(Resgrid.Config.TelemetryConfig.CountlyWebKey)) + { + + + } diff --git a/Web/Resgrid.Web/Models/AccountViewModels/LoginViewModel.cs b/Web/Resgrid.Web/Models/AccountViewModels/LoginViewModel.cs index cfbd9027..79f3078a 100644 --- a/Web/Resgrid.Web/Models/AccountViewModels/LoginViewModel.cs +++ b/Web/Resgrid.Web/Models/AccountViewModels/LoginViewModel.cs @@ -5,10 +5,12 @@ namespace Resgrid.Web.Models.AccountViewModels public class LoginViewModel { [Required] - public string Username { get; set; } + [StringLength(250, ErrorMessage = "The username must be at least 2 characters long and contain only alphanumeric characters.", MinimumLength = 2)] + public string Username { get; set; } [Required] - [DataType(DataType.Password)] + [StringLength(100, ErrorMessage = "The password must be at least 8 characters long, include a number (digit) and an uppercase letter", MinimumLength = 4)] + [DataType(DataType.Password)] public string Password { get; set; } [Display(Name = "Remember me?")] diff --git a/Web/Resgrid.Web/Models/AccountViewModels/RegisterViewModel.cs b/Web/Resgrid.Web/Models/AccountViewModels/RegisterViewModel.cs index a7c9a4f9..c95d79e0 100644 --- a/Web/Resgrid.Web/Models/AccountViewModels/RegisterViewModel.cs +++ b/Web/Resgrid.Web/Models/AccountViewModels/RegisterViewModel.cs @@ -1,4 +1,5 @@ using Resgrid.WebCore.Models; +using Resgrid.Framework; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; @@ -36,7 +37,13 @@ public class RegisterViewModel: GoogleReCaptchaModelBase public string Email { get; set; } [Required] - [StringLength(100, ErrorMessage = "The passowrd must be at least 8 characters long, include a number (digit) and an uppercase letter", MinimumLength = 8)] + [StringLength(100, ErrorMessage = "The password 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/Startup.cs b/Web/Resgrid.Web/Startup.cs index 1c534eb0..e2c27c3d 100644 --- a/Web/Resgrid.Web/Startup.cs +++ b/Web/Resgrid.Web/Startup.cs @@ -350,29 +350,35 @@ public void ConfigureServices(IServiceCollection services) pipeline.MinifyCssFiles("/css/**/*.css"); // Public (external website) public style bundles - pipeline.AddCssBundle("/css/pub-bundle.css", "css/style.css", "css/animate.css", "lib/font-awesome/css/font-awesome.min.css"); + pipeline.AddCssBundle("/css/pub-bundle.css", + "css/style.css", "css/animate.css", "lib/font-awesome/css/font-awesome.min.css"); // Angular App code - pipeline.AddJavaScriptBundle("/js/ng/app.js", "js/ng/vendor.js", "js/ng/runtime.js", "js/ng/polyfills.js", "js/ng/main.js"); + pipeline.AddJavaScriptBundle("/js/ng/app.js", + "js/ng/vendor.js", "js/ng/runtime.js", "js/ng/polyfills.js", "js/ng/main.js"); // Internal app style bundle - pipeline.AddCssBundle("/css/int-bundle.css", "lib/font-awesome/css/font-awesome.min.css", "lib/metisMenu/dist/metisMenu.min.css", "lib/bootstrap-tour/build/css/bootstrap-tour.min.css", + pipeline.AddCssBundle("/css/int-bundle.css", + "lib/font-awesome/css/font-awesome.min.css", "lib/metisMenu/dist/metisMenu.min.css", "lib/bootstrap-tour/build/css/bootstrap-tour.min.css", "css/animate.css", "lib/select2/dist/css/select2.min.css", "clib/kendo/styles/kendo.common.min.css", "clib/kendo/styles/kendo.material.min.css", "lib/toastr/toastr.min.css", "lib/jqueryui/themes/cupertino/jquery-ui.css", "lib/awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css", "clib/picEdit/css/picedit.min.css", "clib/bootstrap-wizard/bootstrap-wizard.css", "lib/quill/dist/quill.snow.css", "lib/leaflet/dist/leaflet.css", "lib/fullcalendar/dist/fullcalendar.min.css", "lib/bstreeview/dist/css/bstreeview.min.css", "lib/selectize/selectize/dist/css/selectize.default.css", "lib/claviska/jquery-minicolors/jquery.minicolors.css", "lib/algolia/autocomplete-theme-classic/dist/theme.css", - "lib/bootstrap-select/dist/css/bootstrap-select.css", "clib/data-tables/datatables.css", "lib/jquery-datetimepicker/build/jquery.datetimepicker.min.css", "css/style.css"); + "lib/bootstrap-select/dist/css/bootstrap-select.css", "clib/data-tables/datatables.css", "lib/jquery-datetimepicker/build/jquery.datetimepicker.min.css", + "lib/deltablot/dropzone/dist/dropzone.css", "css/style.css"); // Internal app js bundle - pipeline.AddJavaScriptBundle("/js/int-bundle.js", "lib/metisMenu/dist/metisMenu.min.js", "lib/slimScroll/jquery.slimscroll.js", "lib/pace/pace.js", + pipeline.AddJavaScriptBundle("/js/int-bundle.js", + "lib/metisMenu/dist/metisMenu.min.js", "lib/slimScroll/jquery.slimscroll.js", "lib/pace/pace.js", "lib/select2/dist/js/select2.full.js", "clib/kendo/js/kendo.web.min.js", "lib/bootstrap-tour/build/js/bootstrap-tour.min.js", "lib/toastr/toastr.min.js", /*"clib/markerwithlabel/markerwithlabel.js",*/ "clib/ujs/jquery-ujs.js", "lib/jquery-validate/dist/jquery.validate.min.js", "lib/jqueryui/jquery-ui.min.js", "lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.min.js", "lib/signalr/dist/browser/signalr.js", "clib/picEdit/js/picedit.min.js", "lib/sweetalert/dist/sweetalert.min.js", "clib/bootstrap-wizard/bootstrap-wizard.min.js", "lib/quill/dist/quill.min.js", "lib/moment/min/moment.min.js", "lib/fullcalendar/dist/fullcalendar.min.js", "lib/leaflet/dist/leaflet.js", "lib/bstreeview/dist/js/bstreeview.min.js", "lib/selectize/selectize/dist/js/standalone/selectize.min.js", "lib/claviska/jquery-minicolors/jquery.minicolors.min.js", "lib/algolia/autocomplete-js/dist/umd/index.production.js", - "lib/bootstrap-select/dist/js/bootstrap-select.js", "clib/data-tables/datatables.js", "lib/jquery-datetimepicker/build/jquery.datetimepicker.full.min.js", "js/site.min.js"); + "lib/bootstrap-select/dist/js/bootstrap-select.js", "clib/data-tables/datatables.js", "lib/jquery-datetimepicker/build/jquery.datetimepicker.full.min.js", + "lib/deltablot/dropzone/dist/dropzone.js", "js/site.min.js"); }); diff --git a/Web/Resgrid.Web/libman.json b/Web/Resgrid.Web/libman.json index dd56a946..ca650e88 100644 --- a/Web/Resgrid.Web/libman.json +++ b/Web/Resgrid.Web/libman.json @@ -284,6 +284,19 @@ { "library": "printthis@0.1.5", "destination": "wwwroot/lib/printthis/" + }, + { + "library": "@deltablot/dropzone@7.3.1", + "destination": "wwwroot/lib/deltablot/dropzone/" + }, + { + "library": "countly-sdk-web@25.4.1", + "destination": "wwwroot/lib/countly-sdk-web/", + "files": [ + "lib/countly.d.ts", + "lib/countly.js", + "lib/countly.min.js" + ] } ] } \ No newline at end of file diff --git a/Web/Resgrid.Web/wwwroot/js/app/common/analytics/resgrid.common.analytics.js b/Web/Resgrid.Web/wwwroot/js/app/common/analytics/resgrid.common.analytics.js index 4119b59a..5e1d53b0 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/common/analytics/resgrid.common.analytics.js +++ b/Web/Resgrid.Web/wwwroot/js/app/common/analytics/resgrid.common.analytics.js @@ -12,6 +12,20 @@ var resgrid; { email: email, name: name, createdOn: new Date(createdOn * 1000), departmentId: departmentId, departmentName: departmentName } // optional: set additional person properties ); } + + if (typeof Countly !== "undefined") { + Countly.q.push(['set_id', userId]); + Countly.q.push(['user_details', { + "name": name, + "email": email, + "organization": departmentName, + "custom": { + "createdOn": new Date(createdOn * 1000), + "departmentId": departmentId + }]); + Countly.q.push(['track_sessions']); + Countly.q.push(['track_pageview']); + } } } analytics.register = register; @@ -20,6 +34,16 @@ var resgrid; if (typeof posthog !== "undefined") { posthog.capture(event) } + + if (typeof Aptabase !== 'undefined') { + Aptabase.trackEvent(event); + } + + if (typeof Countly !== 'undefined') { + Countly.q.push(['add_event', { + "key": event + }]); + } } } analytics.track = track;