diff --git a/Core/Resgrid.Chatbot/Models/ChatbotPlatform.cs b/Core/Resgrid.Chatbot/Models/ChatbotPlatform.cs index 385770d7c..89a89a861 100644 --- a/Core/Resgrid.Chatbot/Models/ChatbotPlatform.cs +++ b/Core/Resgrid.Chatbot/Models/ChatbotPlatform.cs @@ -1,5 +1,7 @@ namespace Resgrid.Chatbot.Models { + // The SMS values (SmsTwilio, SmsSignalWire) are mirrored as constants on + // Resgrid.Model.ChatbotIdentity for consumers that cannot reference this project. public enum ChatbotPlatform { Unknown = 0, diff --git a/Core/Resgrid.Chatbot/Models/ChatbotSession.cs b/Core/Resgrid.Chatbot/Models/ChatbotSession.cs index e336ee5a2..dad0aac76 100644 --- a/Core/Resgrid.Chatbot/Models/ChatbotSession.cs +++ b/Core/Resgrid.Chatbot/Models/ChatbotSession.cs @@ -12,7 +12,13 @@ public enum ChatbotDialogState AwaitingConfirmation = 4, AwaitingParameter = 5, AwaitingAuth = 6, - Completed = 7 + Completed = 7, + + /// + /// Step-up security-PIN prompt for a confirmed destructive action (the user already replied + /// YES; the pending intent executes once the correct PIN is supplied). + /// + AwaitingPin = 8 } public class ChatbotSession diff --git a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs index 86126483a..4d134c444 100644 --- a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs +++ b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs @@ -25,6 +25,9 @@ public class ChatbotIngressService : IChatbotIngressService private readonly IAuthorizationService _authorizationService; private readonly IChatbotDepartmentConfigService _departmentConfigService; private readonly IChatbotRateLimiter _rateLimiter; + private readonly ISecurityPinService _securityPinService; + + private const int MaxPinAttempts = 3; public ChatbotIngressService( IChatbotUserIdentityService userIdentityService, @@ -39,7 +42,8 @@ public ChatbotIngressService( ILimitsService limitsService, IAuthorizationService authorizationService, IChatbotDepartmentConfigService departmentConfigService, - IChatbotRateLimiter rateLimiter) + IChatbotRateLimiter rateLimiter, + ISecurityPinService securityPinService) { _userIdentityService = userIdentityService; _sessionManager = sessionManager; @@ -54,6 +58,7 @@ public ChatbotIngressService( _authorizationService = authorizationService; _departmentConfigService = departmentConfigService; _rateLimiter = rateLimiter; + _securityPinService = securityPinService; } public async Task ProcessMessageAsync(ChatbotMessage message) @@ -66,9 +71,30 @@ public async Task ProcessMessageAsync(ChatbotMessage message) // 1. Identify user from the platform-specific identifier (already-linked only). var identity = await ResolveUserIdentityAsync(message); - // 1b. SMS: a phone number that matches a Resgrid profile but isn't linked yet. Depending - // on the department's RequireLinkingConfirmation setting, either auto-link or run a - // one-time "Reply YES to link" confirmation before trusting the number. + // 1b. SMS identities are only trusted while the linked profile's mobile number is + // verified AND still matches the sending number. Links created before verification was + // required, or made stale by a number change, must not authenticate the sender — drop + // them and fall through to the fresh phone-match below (the number may now belong to a + // different, verified profile). + if (identity != null && IsSmsPlatform(message.Platform)) + { + var linkedProfile = await _userProfileService.GetProfileByUserIdAsync(identity.UserId); + if (linkedProfile == null + || linkedProfile.MobileNumberVerified != true + || !PhoneNumbersMatch(linkedProfile.GetPhoneNumber(), message.From)) + { + // Only physically remove SMS links. The phone fallback can also surface a + // cross-platform identity (e.g. WhatsApp ids are phone numbers); those are + // merely not trusted for this SMS message, not deleted. + if (IsSmsPlatform(identity.Platform)) + await _userIdentityService.UnlinkUserAsync(identity.Id); + identity = null; + } + } + + // 1c. SMS: a phone number that matches a Resgrid profile but isn't linked yet. Only a + // VERIFIED mobile number may authenticate a sender; when it is verified the identity link + // is created silently (it's an internal optimization, not something the user is asked about). if (identity == null && IsSmsPlatform(message.Platform)) { var cleanPhone = message.From?.Replace("+", "").Trim(); @@ -77,19 +103,18 @@ public async Task ProcessMessageAsync(ChatbotMessage message) var profile = await _userProfileService.GetProfileByMobileNumberAsync(cleanPhone); if (profile != null) { - var candidateDepartment = await ResolveActiveDepartmentAsync(profile.UserId); - var requireConfirmation = candidateDepartment == null - || ((await _departmentConfigService.GetConfigAsync(candidateDepartment.DepartmentId))?.RequireLinkingConfirmation ?? true); - - if (!requireConfirmation) + if (profile.MobileNumberVerified == true) { identity = await _userIdentityService.LinkUserAsync( - profile.UserId, message.Platform, message.From, profile.FullName.AsFirstNameLastName, "phone_match"); + profile.UserId, message.Platform, message.From, profile.FullName.AsFirstNameLastName, "phone_match_verified"); } else { - // Returns the prompt / acknowledgement; links the number on a YES reply. - return await HandlePhoneLinkConfirmationAsync(message, profile, candidateDepartment); + return new ChatbotResponse + { + Text = "This mobile number matches a Resgrid profile but hasn't been verified yet. Please verify your mobile number on your Resgrid profile page, then text again.", + Processed = true + }; } } } @@ -211,34 +236,49 @@ await _userIdentityService.LinkUserAsync( var reply = message.Text?.Trim().ToUpperInvariant(); if (reply == "YES" || reply == "Y" || reply == "CONFIRM" || reply == "OK") { - var pendingType = session.PendingIntent.Value; - - // Locate the owning handler BEFORE mutating session state. If none can handle - // the pending intent, leave the session parked in AwaitingConfirmation and return - // an explicit error rather than silently dropping the confirmation. - var confirmHandler = _actionHandlers.FirstOrDefault(h => h.CanHandle(pendingType)); - if (confirmHandler == null) + // Step-up auth: when the department forces security-PIN usage (or the user opted + // in), a confirmed destructive action additionally requires the user's 4-digit PIN + // before it executes. + if (await _securityPinService.IsPinRequiredAsync(identity.UserId, department.DepartmentId)) { - return new ChatbotResponse + // Provision a missing PIN BEFORE persisting the awaiting-PIN state: if + // provisioning fails (no profile, or a thrown error caught by the outer + // handler) the session stays parked in AwaitingConfirmation instead of + // being stranded awaiting a PIN that doesn't exist. + bool pinGenerated = false; + if (!await _securityPinService.HasPinAsync(identity.UserId)) { - Text = "Unable to complete confirmation — please try again or contact support.", - Processed = false - }; + var provisioned = await _securityPinService.EnsurePinAsync(identity.UserId, department.DepartmentId); + if (provisioned == null || string.IsNullOrWhiteSpace(provisioned.SecurityPin)) + { + return new ChatbotResponse + { + Text = "This action requires a security PIN, but one couldn't be set up for your account. Please set a PIN on your Resgrid profile page, then reply YES again.", + Processed = true + }; + } + pinGenerated = true; + } + + session.State = ChatbotDialogState.AwaitingPin; + session.Context["__pinAttempts"] = "0"; + await _sessionManager.SaveSessionAsync(session); + + if (pinGenerated) + { + // Department forces PINs but this user didn't have one yet — one was + // generated so they can look it up on their profile page and continue. + return new ChatbotResponse + { + Text = "This action requires your security PIN. A PIN has been generated for you — view or change it on your Resgrid profile page, then reply with the 4-digit PIN to continue, or NO to cancel.", + Processed = true + }; + } + + return new ChatbotResponse { Text = "For security, reply with your 4-digit security PIN to complete this action, or NO to cancel.", Processed = true }; } - var confirmedIntent = new ChatbotIntent - { - Type = pendingType, - Parameters = new Dictionary(session.Context) { ["__confirmed"] = "true" } - }; - - var confirmResponse = await confirmHandler.HandleAsync(message, confirmedIntent, session); - confirmResponse.Intent = confirmedIntent; - session.Context.Clear(); - session.State = ChatbotDialogState.Idle; - session.PendingIntent = null; - await _sessionManager.SaveSessionAsync(session); - return confirmResponse; + return await DispatchConfirmedIntentAsync(message, session); } else if (reply == "NO" || reply == "N" || reply == "CANCEL") { @@ -252,6 +292,46 @@ await _userIdentityService.LinkUserAsync( } } + // 5b. PIN step-up for an already-confirmed destructive action: the user replied YES and a + // security PIN is required, so the pending intent only executes on a correct PIN. + if (session.State == ChatbotDialogState.AwaitingPin && session.PendingIntent.HasValue) + { + var pinReply = message.Text?.Trim(); + + if (IsNegative(pinReply)) + { + session.Reset(); + await _sessionManager.SaveSessionAsync(session); + return new ChatbotResponse { Text = "Cancelled.", Processed = true }; + } + + if (!SecurityPinUtility.IsValidFormat(pinReply)) + return new ChatbotResponse { Text = "Please reply with your 4-digit security PIN, or NO to cancel.", Processed = true }; + + if (await _securityPinService.ValidatePinAsync(identity.UserId, pinReply)) + { + session.Context.Remove("__pinAttempts"); + return await DispatchConfirmedIntentAsync(message, session); + } + else + { + session.Context.TryGetValue("__pinAttempts", out var attemptsRaw); + int.TryParse(attemptsRaw, out var attempts); + attempts++; + + if (attempts >= MaxPinAttempts) + { + session.Reset(); + await _sessionManager.SaveSessionAsync(session); + return new ChatbotResponse { Text = "Too many incorrect PIN attempts. The action has been cancelled.", Processed = true }; + } + + session.Context["__pinAttempts"] = attempts.ToString(); + await _sessionManager.SaveSessionAsync(session); + return new ChatbotResponse { Text = "Incorrect PIN. Reply with your 4-digit security PIN, or NO to cancel.", Processed = true }; + } + } + if (session.State != ChatbotDialogState.Idle) { var continuationResult = await _conversationEngine.HandleContinuationAsync(message, session); @@ -346,12 +426,6 @@ private static bool IsEmergencyText(string text) return t.Contains("mayday") || t.Contains("emergency") || t.Contains("officer down") || t.Contains("firefighter down"); } - private static bool IsAffirmative(string text) - { - var t = text?.Trim().ToLowerInvariant(); - return t == "yes" || t == "y" || t == "confirm"; - } - private static bool IsNegative(string text) { var t = text?.Trim().ToLowerInvariant(); @@ -359,55 +433,60 @@ private static bool IsNegative(string text) } /// - /// One-time confirmation before linking an unrecognized SMS number to the matching Resgrid - /// account. Prompts on first contact, links on YES, cancels on NO. + /// Executes the pending (already confirmed, and PIN-verified when required) destructive intent. + /// The owning handler is located BEFORE any session state is mutated: if none can handle the + /// pending intent the session stays parked and an explicit error is returned rather than + /// silently dropping the confirmation. /// - private async Task HandlePhoneLinkConfirmationAsync(ChatbotMessage message, Model.UserProfile profile, Model.Department department) + private async Task DispatchConfirmedIntentAsync(ChatbotMessage message, ChatbotSession session) { - // A matched profile may have no active department membership, in which case the caller - // passes a null department. The phone-link confirmation only tracks trust of the number - // (linking doesn't depend on a department), so fall back to department id 0 for the - // temporary session rather than dereferencing a null department. - var session = await _sessionManager.GetOrCreateSessionAsync(profile.UserId, department?.DepartmentId ?? 0, message.Platform, message.From); - - var awaitingConfirmation = session.State == ChatbotDialogState.AwaitingConfirmation - && session.Context != null - && session.Context.TryGetValue("pendingAction", out var pendingAction) - && pendingAction == "confirm_phone_link"; - - if (awaitingConfirmation) - { - if (IsAffirmative(message.Text)) - { - await _userIdentityService.LinkUserAsync( - profile.UserId, message.Platform, message.From, profile.FullName.AsFirstNameLastName, "phone_match_confirmed"); - session.State = ChatbotDialogState.Idle; - session.Context.Remove("pendingAction"); - await _sessionManager.SaveSessionAsync(session); - return new ChatbotResponse { Text = "Your number is now linked to your Resgrid account. Text HELP to see what you can do.", Processed = true }; - } + var pendingType = session.PendingIntent.Value; - if (IsNegative(message.Text)) + var confirmHandler = _actionHandlers.FirstOrDefault(h => h.CanHandle(pendingType)); + if (confirmHandler == null) + { + return new ChatbotResponse { - await _sessionManager.EndSessionAsync(session.SessionId); - return new ChatbotResponse { Text = "No problem — I won't link this number. Reply LINK anytime to connect your account.", Processed = true }; - } - - return new ChatbotResponse { Text = "Please reply YES to link this number to your Resgrid account, or NO to cancel.", Processed = false }; + Text = "Unable to complete confirmation — please try again or contact support.", + Processed = false + }; } - // First contact for this number: ask for confirmation before linking. - session.State = ChatbotDialogState.AwaitingConfirmation; - session.Context["pendingAction"] = "confirm_phone_link"; - await _sessionManager.SaveSessionAsync(session); - - return new ChatbotResponse + var confirmedIntent = new ChatbotIntent { - Text = $"We found a Resgrid account for {profile.FirstName}. Reply YES to link this number, or NO to cancel.", - Processed = true + Type = pendingType, + Parameters = new Dictionary(session.Context) { ["__confirmed"] = "true" } }; + + var confirmResponse = await confirmHandler.HandleAsync(message, confirmedIntent, session); + confirmResponse.Intent = confirmedIntent; + session.Context.Clear(); + session.State = ChatbotDialogState.Idle; + session.PendingIntent = null; + await _sessionManager.SaveSessionAsync(session); + return confirmResponse; + } + + /// + /// Compares a profile phone number against a sender number, ignoring formatting and tolerating + /// a leading US country code on either side (mirrors GetProfileByMobileNumberAsync). + /// + private static bool PhoneNumbersMatch(string profileNumber, string senderNumber) + { + var a = NormalizePhone(profileNumber); + var b = NormalizePhone(senderNumber); + + if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) + return false; + + return a == b + || (a.Length == 11 && a[0] == '1' && a.Substring(1) == b) + || (b.Length == 11 && b[0] == '1' && b.Substring(1) == a); } + private static string NormalizePhone(string number) + => number?.Replace(" ", "").Replace("(", "").Replace(")", "").Replace("+", "").Replace("-", "").Replace(".", "").Trim(); + private async Task ResolveActiveDepartmentAsync(string userId) { // Shared resolution: the user's active (then default, then first) membership, preferring one whose diff --git a/Core/Resgrid.Framework/SecurityPinUtility.cs b/Core/Resgrid.Framework/SecurityPinUtility.cs new file mode 100644 index 000000000..36ccd0aff --- /dev/null +++ b/Core/Resgrid.Framework/SecurityPinUtility.cs @@ -0,0 +1,71 @@ +using System.Security.Cryptography; + +namespace Resgrid.Framework +{ + /// + /// Generation and validation rules for the 4-digit user security PIN used as a step-up + /// (2FA-style) check for dangerous/department-wide chatbot and SMS actions. + /// + public static class SecurityPinUtility + { + public const int PinLength = 4; + + /// True when the value is exactly ASCII digits. + public static bool IsValidFormat(string pin) + { + if (string.IsNullOrWhiteSpace(pin) || pin.Length != PinLength) + return false; + + foreach (var c in pin) + { + if (c < '0' || c > '9') + return false; + } + + return true; + } + + /// + /// True for trivially guessable PINs: all-same-digit (0000, 1111, ...) and consecutive + /// ascending/descending runs (1234, 0123, 4321, 9876, ...). + /// + public static bool IsWeak(string pin) + { + if (!IsValidFormat(pin)) + return true; + + bool allSame = true, ascending = true, descending = true; + for (int i = 1; i < pin.Length; i++) + { + if (pin[i] != pin[0]) + allSame = false; + if (pin[i] != pin[i - 1] + 1) + ascending = false; + if (pin[i] != pin[i - 1] - 1) + descending = false; + } + + return allSame || ascending || descending; + } + + /// Valid format and not weak. + public static bool IsAcceptable(string pin) => IsValidFormat(pin) && !IsWeak(pin); + + /// Generates a random PIN that passes using a crypto RNG. + public static string Generate() + { + using var rng = RandomNumberGenerator.Create(); + var bytes = new byte[4]; + + while (true) + { + rng.GetBytes(bytes); + uint value = (uint)(bytes[0] | bytes[1] << 8 | bytes[2] << 16 | bytes[3] << 24); + string pin = (value % 10000).ToString().PadLeft(PinLength, '0'); + + if (IsAcceptable(pin)) + return pin; + } + } + } +} diff --git a/Core/Resgrid.Framework/StringHelpers.cs b/Core/Resgrid.Framework/StringHelpers.cs index 872be8ce6..5caee30e6 100644 --- a/Core/Resgrid.Framework/StringHelpers.cs +++ b/Core/Resgrid.Framework/StringHelpers.cs @@ -21,6 +21,22 @@ public sealed record PasswordComplexityResult( public static class StringHelpers { + /// + /// Masks a phone number for diagnostic logging (PII redaction), keeping only the last + /// four digits (e.g. "***4567"). Values with four or fewer characters are fully masked. + /// + public static string MaskPhoneNumber(string number) + { + if (string.IsNullOrWhiteSpace(number)) + return "***"; + + var trimmed = number.Trim(); + if (trimmed.Length <= 4) + return "***"; + + return "***" + trimmed.Substring(trimmed.Length - 4); + } + /// /// Remove HTML tags from string using char array. /// diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx index d931429c1..b101355df 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx @@ -1,4 +1,4 @@ - +