Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Core/Resgrid.Config/TelemetryConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions Core/Resgrid.Framework/PasswordComplexityAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;

namespace Resgrid.Framework
{
/// <summary>
/// Validation attribute for password complexity requirements
/// </summary>
[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 });
}
}
}
81 changes: 81 additions & 0 deletions Core/Resgrid.Framework/StringHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@

namespace Resgrid.Framework
{
/// <summary>
/// Result of password complexity verification
/// </summary>
public sealed record PasswordComplexityResult(
bool IsValid,
List<string> Errors);

public static class StringHelpers
{
/// <summary>
Expand Down Expand Up @@ -247,5 +254,79 @@ public static string SanitizeCoordinatesString(string source)
return resultStrBuilder.ToString();
}

/// <summary>
/// Verifies password complexity against security requirements
/// </summary>
/// <param name="password">The password to verify</param>
/// <param name="minLength">Minimum password length (default: 8)</param>
/// <param name="requireUppercase">Require at least one uppercase letter (default: true)</param>
/// <param name="requireLowercase">Require at least one lowercase letter (default: true)</param>
/// <param name="requireDigit">Require at least one digit (default: true)</param>
/// <param name="requireSpecialChar">Require at least one special character (default: false)</param>
/// <returns>PasswordComplexityResult indicating validity and any errors</returns>
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<string>();

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);
}

/// <summary>
/// Validates password complexity using default Resgrid requirements
/// </summary>
/// <param name="password">The password to validate</param>
/// <returns>True if password meets complexity requirements</returns>
public static bool IsValidPassword(string password)
{
var result = VerifyPasswordComplexity(password);
return result.IsValid;
}

}
}
4 changes: 2 additions & 2 deletions Core/Resgrid.Services/PushService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public PushService(IPushLogsService pushLogsService, INotificationProvider notif

public async Task<bool> 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;
Expand All @@ -61,7 +61,7 @@ public async Task<bool> UnRegister(PushUri pushUri)

public async Task<bool> 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;
Expand Down
90 changes: 90 additions & 0 deletions Tests/Resgrid.Tests/Framework/PasswordComplexityExamples.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System;
using System.Linq;
using Resgrid.Framework;

namespace Resgrid.Tests.Framework
{
/// <summary>
/// Example usage and tests for password complexity verification
/// </summary>
public static class PasswordComplexityExamples
{
/// <summary>
/// Demonstrates how to use the password complexity verification
/// </summary>
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();
}
}

/// <summary>
/// Simple validation method for quick checks using Resgrid defaults
/// </summary>
public static bool ValidatePasswordForResgrid(string password)
{
return StringHelpers.VerifyPasswordComplexity(
password,
minLength: 8,
requireUppercase: true,
requireLowercase: true,
requireDigit: true,
requireSpecialChar: false).IsValid;
}
}
}
17 changes: 12 additions & 5 deletions Web/Resgrid.Web/Areas/User/Models/AddPersonModel.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down Expand Up @@ -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]
Expand Down
13 changes: 13 additions & 0 deletions Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@
})
</script>
}

@if (!String.IsNullOrWhiteSpace(Resgrid.Config.TelemetryConfig.CountlyUrl) && !String.IsNullOrWhiteSpace(Resgrid.Config.TelemetryConfig.CountlyWebKey))
{
<script type='text/javascript' src="~/lib/countly-sdk-web/lib/countly.min.js"></script>
<script type='text/javascript'>
if (Countly && window["Countly"]) {
Countly.init({
app_key: "@Resgrid.Config.TelemetryConfig.CountlyWebKey",
url: "@Resgrid.Config.TelemetryConfig.CountlyUrl"
});
}
</script>
}
</head>
<body>
<!-- Wrapper-->
Expand Down
6 changes: 4 additions & 2 deletions Web/Resgrid.Web/Models/AccountViewModels/LoginViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Comment on lines +8 to +9
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Username error message contradicts actual allowed characters

Identity allows spaces and -._@ (see Startup configuration). Either adjust the message or add a regex to enforce the identity-allowed set. Suggested: simplify the message.

-        [StringLength(250, ErrorMessage = "The username must be at least 2 characters long and contain only alphanumeric characters.", MinimumLength = 2)]
+        [StringLength(250, ErrorMessage = "The username must be at least 2 characters long.", MinimumLength = 2)]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[StringLength(250, ErrorMessage = "The username must be at least 2 characters long and contain only alphanumeric characters.", MinimumLength = 2)]
public string Username { get; set; }
[StringLength(250, ErrorMessage = "The username must be at least 2 characters long.", MinimumLength = 2)]
public string Username { get; set; }
🤖 Prompt for AI Agents
In Web/Resgrid.Web/Models/AccountViewModels/LoginViewModel.cs around lines 8-9,
the StringLength error message incorrectly states allowed characters
(alphanumeric only) while Identity permits spaces and -._@; either simplify the
message to only describe length (e.g., "The username must be between 2 and 250
characters.") or add a RegularExpression attribute that matches the exact
allowed character set used in Startup (include spaces and - . _ @ plus
alphanumerics) so the validation message and enforcement align.


[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; }
Comment on lines +12 to 14
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Login password length constraint may block valid users

Enforcing min length here can prevent sign-in for legacy accounts whose passwords predate current policy. Recommend removing StringLength (validation occurs on change/reset), or align MinimumLength with current policy (8) if you’re certain no existing accounts fall below.

-        [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)]
+        [DataType(DataType.Password)]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[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; }
[DataType(DataType.Password)]
public string Password { get; set; }
🤖 Prompt for AI Agents
In Web/Resgrid.Web/Models/AccountViewModels/LoginViewModel.cs around lines
12-14, the StringLength attribute on Password enforces a minimum length (4) at
sign-in which can block legacy accounts; remove the StringLength attribute
entirely so login does not validate password length (perform length validation
only on password change/reset), or if you are sure no existing accounts use
shorter passwords, update MinimumLength to 8 to match current policy.


[Display(Name = "Remember me?")]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Resgrid.WebCore.Models;
using Resgrid.Framework;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

Expand Down Expand Up @@ -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; }
Expand Down
Loading
Loading