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 @@
-
+