diff --git a/Messaging.Tests/UnitTest1.cs b/Messaging.Tests/UnitTest1.cs index 3f04482c..acf66342 100644 --- a/Messaging.Tests/UnitTest1.cs +++ b/Messaging.Tests/UnitTest1.cs @@ -21,6 +21,7 @@ namespace Messaging.Tests public class Functional : IClassFixture> { private readonly HttpClient _httpClient; + private readonly WebApplicationFactory _factory; private readonly IConfiguration _configuration; private readonly ITestOutputHelper _output; private readonly AppSettings _appSettings; @@ -28,6 +29,7 @@ public class Functional : IClassFixture> public Functional(WebApplicationFactory factory, ITestOutputHelper output) { + _factory = factory; _httpClient = factory.CreateClient(); _configuration = new ConfigurationBuilder() .AddJsonFile("appsettings.json") @@ -183,6 +185,51 @@ public async Task SendSMSMessageAsync() Assert.True(details.MessageSent); } + // A FusionPBX API key that does not own the sending MSISDN must be rejected. This is the core security + // guarantee for #512: a customer's key can only send from numbers registered to their FusionPBX domain. + [Fact] + public async Task SendSMSWithFusionPBXKeyForUnownedNumberIsRejectedAsync() + { + string apiKey = _configuration.GetConnectionString("FusionPBXTestApiKey") ?? string.Empty; + Assert.SkipWhen(string.IsNullOrWhiteSpace(apiKey), "No FusionPBXTestApiKey configured; skipping FusionPBX ownership test."); + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new("Bearer", apiKey); + + // 5555551212 is a reserved/fictional number that no real FusionPBX domain will have as a destination. + var message = new SendMessageRequest { MediaURLs = [], Message = "This should be rejected.", MSISDN = "5555551212", To = "2068589312" }; + var response = await client.PostAsJsonAsync("/message/send?test=true", message); + var details = await response.Content.ReadFromJsonAsync(); + + _output.WriteLine(await response.Content.ReadAsStringAsync()); + Assert.NotNull(details); + Assert.False(details.MessageSent); + Assert.Contains("not registered to your account", details.Message); + } + + // A FusionPBX API key sending from a number it owns should be accepted. Requires a real key plus a number + // that is a destination in that key's domain, both supplied via configuration. + [Fact] + public async Task SendSMSWithFusionPBXKeyForOwnedNumberIsAcceptedAsync() + { + string apiKey = _configuration.GetConnectionString("FusionPBXTestApiKey") ?? string.Empty; + string ownedNumber = _configuration.GetConnectionString("FusionPBXTestOwnedNumber") ?? string.Empty; + Assert.SkipWhen(string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(ownedNumber), + "No FusionPBXTestApiKey / FusionPBXTestOwnedNumber configured; skipping FusionPBX ownership test."); + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new("Bearer", apiKey); + + var message = new SendMessageRequest { MediaURLs = [], Message = "This is an owned-number test.", MSISDN = ownedNumber, To = ownedNumber }; + var response = await client.PostAsJsonAsync("/message/send?test=true", message); + var details = await response.Content.ReadFromJsonAsync(); + + _output.WriteLine(await response.Content.ReadAsStringAsync()); + Assert.NotNull(details); + Assert.True(response.IsSuccessStatusCode); + Assert.True(details.MessageSent); + } + public record TokenResponse(string access_token, string token_type, int expires_in, string scope); //[Fact] diff --git a/Messaging/ApiAccessAuthorization.cs b/Messaging/ApiAccessAuthorization.cs new file mode 100644 index 00000000..69fe3995 --- /dev/null +++ b/Messaging/ApiAccessAuthorization.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +using System.Threading.Tasks; + +namespace Messaging +{ + /// + /// Authorization requirement satisfied by EITHER an authenticated ops.acceleratenetworks.com Identity user + /// OR a valid FusionPBX API key presented as a bearer token. This lets FusionPBX users reach the same + /// endpoints as operators; per-number ownership is then enforced inside each endpoint. + /// + public sealed class ApiAccessRequirement : IAuthorizationRequirement { } + + public sealed class ApiAccessHandler(IHttpContextAccessor httpContextAccessor, AppSettings appSettings) + : AuthorizationHandler + { + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, ApiAccessRequirement requirement) + { + // Existing behavior: an authenticated Identity user (operator) is always allowed. + if (context.User?.Identity?.IsAuthenticated == true) + { + context.Succeed(requirement); + return; + } + + // New behavior: a valid, enabled FusionPBX API key in the bearer token is allowed. + var httpContext = httpContextAccessor.HttpContext; + if (httpContext is not null) + { + var user = await FusionPBXAuth.AuthenticateAsync(httpContext, appSettings.ConnectionStrings.FusionPBXConnectionString); + if (user.IsAuthenticated()) + { + context.Succeed(requirement); + } + } + } + } +} diff --git a/Messaging/FusionPBXAuth.cs b/Messaging/FusionPBXAuth.cs new file mode 100644 index 00000000..4e7520cd --- /dev/null +++ b/Messaging/FusionPBXAuth.cs @@ -0,0 +1,97 @@ +using Microsoft.AspNetCore.Http; + +using NumberSearch.DataAccess.FusionPBX; + +using Serilog; + +using System; +using System.Threading.Tasks; + +namespace Messaging +{ + /// + /// Authenticates requests to the Messaging API using API keys generated by existing user accounts in FusionPBX, + /// and restricts those requests to the phone numbers the user owns (the destinations registered to their domain). + /// This runs alongside the existing ops.acceleratenetworks.com Identity bearer token authorization: an operator + /// token continues to work unrestricted, while a FusionPBX key is scoped to the caller's own numbers. + /// + public static class FusionPBXAuth + { + /// + /// Pulls the raw bearer token out of the Authorization header, if present. + /// + public static string? GetBearerToken(HttpContext context) + { + string? header = context.Request.Headers.Authorization; + + if (string.IsNullOrWhiteSpace(header)) + { + return null; + } + + const string prefix = "Bearer "; + return header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? header[prefix.Length..].Trim() + : header.Trim(); + } + + /// + /// Resolves the bearer token to an enabled FusionPBX user, or returns a default User (user_uuid == Guid.Empty) + /// when the token is not a valid FusionPBX API key. A default result means "not a FusionPBX request" and the + /// caller should fall back to the existing Identity authorization. + /// + public static async Task AuthenticateAsync(HttpContext context, string fusionPBXConnectionString) + { + string? token = GetBearerToken(context); + + if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(fusionPBXConnectionString)) + { + return default; + } + + try + { + var user = await User.GetByApiKeyAsync(token.AsMemory(), fusionPBXConnectionString.AsMemory()); + + // FusionPBX stores user_enabled as the text 'true' or 'false' rather than as a boolean. + return user.user_uuid != Guid.Empty && user.user_enabled is "true" ? user : default; + } + catch (Exception ex) + { + Log.Error("[FusionPBX] Failed to authenticate an API key against the FusionPBX database: {Message}", ex.Message); + return default; + } + } + + /// + /// Is the supplied FusionPBX user a real, enabled user? + /// + public static bool IsAuthenticated(this User user) => user.user_uuid != Guid.Empty; + + /// + /// Does the authenticated FusionPBX user own the supplied phone number? Ownership means the number is a + /// destination registered to the user's domain in FusionPBX. Matching tolerates country code prefixes, + /// so +12065551234 and 12065551234 both match a destination stored as 2065551234. + /// + public static async Task OwnsNumberAsync(this User user, string dialedNumber, string fusionPBXConnectionString) + { + if (!user.IsAuthenticated() || string.IsNullOrWhiteSpace(dialedNumber)) + { + return false; + } + + try + { + var destination = await DestinationDetails.GetByDialedNumberAndDomainAsync( + dialedNumber.AsMemory(), user.domain_uuid ?? Guid.Empty, fusionPBXConnectionString.AsMemory()); + + return destination.destination_uuid != Guid.Empty; + } + catch (Exception ex) + { + Log.Error("[FusionPBX] Failed to verify number ownership against the FusionPBX database: {Message}", ex.Message); + return false; + } + } + } +} diff --git a/Messaging/Messaging.csproj b/Messaging/Messaging.csproj index a06c9902..1af4f293 100644 --- a/Messaging/Messaging.csproj +++ b/Messaging/Messaging.csproj @@ -92,6 +92,7 @@ + diff --git a/Messaging/Program.cs b/Messaging/Program.cs index a691688f..7abc0061 100644 --- a/Messaging/Program.cs +++ b/Messaging/Program.cs @@ -118,7 +118,7 @@ { Title = "sms.callpipe.com", Version = "v1", - Description = "This API abstracts the sending and receiving of SMS/MMS messages to and from our upstream vendors. To use this API, you will need credentials to login to ops.acceleratenetworks.com. Please contact Dan to have an account made. Then you can provide those credentials to the /login endpoint of this API. In the JSON response body from the /login endpoint you'll need to copy the value of the 'accessToken' field and paste it into the Authentication => Bearer => Bearer Token: Token box positioned just to the right of this text summary, this will provide the bearer token to all of the endpoints in this API docuemnt that require it. You can read the APGLv3 licensed code for this API on Github: https://github.com/AccelerateNetworks/NumberSearch/blob/master/Messaging/Program.cs", + Description = "This API abstracts the sending and receiving of SMS/MMS messages to and from our upstream vendors. Every endpoint that requires authentication accepts a bearer token, and there are two ways to get one. Customers: generate an API key on your user account in FusionPBX and paste it into the Authentication => Bearer => Bearer Token: Token box positioned just to the right of this text summary. A FusionPBX API key can send messages, register for inbound messages, and view message history only for the phone numbers registered to your FusionPBX domain, it does not need to be exchanged at the /login endpoint. Accelerate Networks staff: provide your ops.acceleratenetworks.com credentials to the /login endpoint, then copy the value of the 'accessToken' field from the JSON response into the same Bearer Token box. Please contact Dan to have an ops account made. You can read the AGPLv3 licensed code for this API on Github: https://github.com/AccelerateNetworks/NumberSearch/blob/master/Messaging/Program.cs", Contact = new() { Name = string.Empty, @@ -150,10 +150,15 @@ builder.Services.AddAuthentication().AddBearerToken(IdentityConstants.BearerScheme).AddJwtBearer(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddScoped(); + builder.Services.AddAuthorizationBuilder().AddPolicy("api", p => { - p.RequireAuthenticatedUser(); + // Satisfied by an authenticated Identity (ops) user OR a valid FusionPBX API key. The scheme is still added + // so that Identity bearer tokens are parsed into a principal; the requirement handles the FusionPBX fallback. p.AddAuthenticationSchemes(IdentityConstants.BearerScheme); + p.Requirements.Add(new ApiAccessRequirement()); }); builder.Services.Configure(options => @@ -225,8 +230,8 @@ // These two endpoints /login and /refresh were extracted from app.MapGroup("/auth").MapIdentityApi(); to hide the registration endpoint. app.MapPost("/login", Endpoints.LoginAsync).AddOpenApiOperationTransformer((operation, context, ct) => { - operation.Summary = "Get a token"; - operation.Description = "Provide your credentials from ops.acceleratenetworks.com to get an accessToken to query this API."; + operation.Summary = "Get a token (staff only)"; + operation.Description = "Accelerate Networks staff can provide their credentials from ops.acceleratenetworks.com to get an accessToken to query this API. Customers don't need this endpoint, use an API key generated by your user account in FusionPBX as the bearer token instead."; return Task.CompletedTask; }); @@ -270,7 +275,7 @@ .AddOpenApiOperationTransformer((operation, context, ct) => { operation.Summary = "Register a client"; - operation.Description = "To recieve messages, clients (a phone number and a callback url) will have to be registered using this endpoint."; + operation.Description = "To recieve messages, clients (a phone number and a callback url) will have to be registered using this endpoint. When authenticated with a FusionPBX API key, you can only register phone numbers that are registered to your FusionPBX domain."; return Task.CompletedTask; }); @@ -297,7 +302,7 @@ .AddOpenApiOperationTransformer((operation, context, ct) => { operation.Summary = "View all sent and received messages"; - operation.Description = "This is intended to help you debug problems with message sending and delivery so you can see if it's this API or the upstream vendor that is causing problems."; + operation.Description = "This is intended to help you debug problems with message sending and delivery so you can see if it's this API or the upstream vendor that is causing problems. When authenticated with a FusionPBX API key, the asDialed parameter is required and must be a phone number registered to your FusionPBX domain."; return Task.CompletedTask; }); @@ -324,7 +329,7 @@ .AddOpenApiOperationTransformer((operation, context, ct) => { operation.Summary = "Send an SMS or MMS Message"; - operation.Description = "Submit outbound messages to this endpoint. The 'to' field is a comma separated list of dialed numbers, or a single dialed number without commas. The 'msisdn' the dialed number of the client that is sending the message. The 'message' field is a string. No validation of the message field occurs before it is forwarded to our upstream vendors. If you include any file paths in the MediaURLs array the message will be sent as an MMS. If the Message field is loner than 160 characters, but less than 1600 characters, the message will be sent as an MMS, not an SMS, even if no MediaURLs are provided."; + operation.Description = "Submit outbound messages to this endpoint. The 'to' field is a comma separated list of dialed numbers, or a single dialed number without commas. The 'msisdn' the dialed number of the client that is sending the message. The 'message' field is a string. No validation of the message field occurs before it is forwarded to our upstream vendors. If you include any file paths in the MediaURLs array the message will be sent as an MMS. If the Message field is loner than 160 characters, but less than 1600 characters, the message will be sent as an MMS, not an SMS, even if no MediaURLs are provided. When authenticated with a FusionPBX API key, the 'msisdn' must be a phone number registered to your FusionPBX domain."; return Task.CompletedTask; }); @@ -777,7 +782,7 @@ public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransf Type = SecuritySchemeType.Http, Scheme = "bearer", // "bearer" refers to the header name here In = ParameterLocation.Header, - BearerFormat = "Json Web Token" + BearerFormat = "FusionPBX API key or access token from /login" } }; document.Components ??= new OpenApiComponents(); @@ -997,7 +1002,7 @@ public static async Task, BadRequest, NotFoun } public static async Task, BadRequest>> - RegisterClientAsync(RegistrationRequest registration, AppSettings appSettings, MessagingContext db) + RegisterClientAsync(RegistrationRequest registration, AppSettings appSettings, MessagingContext db, HttpContext? context = null) { var checkAsDialed = PhoneNumbersNA.PhoneNumber.TryParse(registration.DialedNumber, out var asDialedNumber); if (!checkAsDialed) @@ -1011,6 +1016,26 @@ public static async Task, BadRequest, NotFound, BadRequest, NotFound, BadRequest>> - AllMessagesAsync(MessagingContext db, string? asDialed) + AllMessagesAsync(MessagingContext db, string? asDialed, AppSettings appSettings, HttpContext? context = null) { + // If the request was authenticated with a FusionPBX API key, restrict message history to a number the user owns. + if (context is not null) + { + var fusionPBXUser = await FusionPBXAuth.AuthenticateAsync(context, appSettings.ConnectionStrings.FusionPBXConnectionString); + if (fusionPBXUser.IsAuthenticated()) + { + if (string.IsNullOrWhiteSpace(asDialed)) + { + return TypedResults.BadRequest("The asDialed parameter is required. You may only view messages for phone numbers you own."); + } + + bool ownsNumber = await fusionPBXUser.OwnsNumberAsync(asDialed, appSettings.ConnectionStrings.FusionPBXConnectionString); + if (!ownsNumber) + { + return TypedResults.BadRequest($"The number {asDialed} is not registered to your account. You may only view messages for phone numbers you own."); + } + } + } + if (string.IsNullOrWhiteSpace(asDialed)) { try @@ -1531,8 +1575,27 @@ public static async Task, NotFound, BadRequest, BadRequest>> - SendMessageAsync([Microsoft.AspNetCore.Mvc.FromBody] SendMessageRequest message, bool? test, AppSettings appSettings, MessagingContext db) + SendMessageAsync([Microsoft.AspNetCore.Mvc.FromBody] SendMessageRequest message, bool? test, AppSettings appSettings, MessagingContext db, HttpContext? context = null) { + // If the request was authenticated with a FusionPBX API key, restrict sending to numbers the user owns. + // Operator (Identity) requests and internal callers (context is null) are not restricted. + if (context is not null) + { + var fusionPBXUser = await FusionPBXAuth.AuthenticateAsync(context, appSettings.ConnectionStrings.FusionPBXConnectionString); + if (fusionPBXUser.IsAuthenticated()) + { + bool ownsSender = await fusionPBXUser.OwnsNumberAsync(message?.MSISDN ?? string.Empty, appSettings.ConnectionStrings.FusionPBXConnectionString); + if (!ownsSender) + { + return TypedResults.BadRequest(new SendMessageResponse + { + MessageSent = false, + Message = $"The number {message?.MSISDN} is not registered to your account. You may only send messages from phone numbers you own." + }); + } + } + } + var toForward = new toForwardOutbound { username = appSettings.ConnectionStrings.PComNetUsername, @@ -2706,6 +2769,7 @@ public class Connectionstrings public string FirstPointOutboundMMSMessageURL { get; set; } = string.Empty; public string EmailUsername { get; set; } = string.Empty; public string EmailPassword { get; set; } = string.Empty; + public string FusionPBXConnectionString { get; set; } = string.Empty; } }