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
47 changes: 47 additions & 0 deletions Messaging.Tests/UnitTest1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ namespace Messaging.Tests
public class Functional : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _httpClient;
private readonly WebApplicationFactory<Program> _factory;
private readonly IConfiguration _configuration;
private readonly ITestOutputHelper _output;
private readonly AppSettings _appSettings;


public Functional(WebApplicationFactory<Program> factory, ITestOutputHelper output)
{
_factory = factory;
_httpClient = factory.CreateClient();
_configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
Expand Down Expand Up @@ -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<SendMessageResponse>();

_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<SendMessageResponse>();

_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]
Expand Down
40 changes: 40 additions & 0 deletions Messaging/ApiAccessAuthorization.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;

using System.Threading.Tasks;

namespace Messaging
{
/// <summary>
/// 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.
/// </summary>
public sealed class ApiAccessRequirement : IAuthorizationRequirement { }

public sealed class ApiAccessHandler(IHttpContextAccessor httpContextAccessor, AppSettings appSettings)

Check failure on line 16 in Messaging/ApiAccessAuthorization.cs

View workflow job for this annotation

GitHub Actions / build

The type or namespace name 'AppSettings' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 16 in Messaging/ApiAccessAuthorization.cs

View workflow job for this annotation

GitHub Actions / build

The type or namespace name 'AppSettings' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 16 in Messaging/ApiAccessAuthorization.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

The type or namespace name 'AppSettings' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 16 in Messaging/ApiAccessAuthorization.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

The type or namespace name 'AppSettings' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 16 in Messaging/ApiAccessAuthorization.cs

View workflow job for this annotation

GitHub Actions / Analyze (javascript)

The type or namespace name 'AppSettings' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 16 in Messaging/ApiAccessAuthorization.cs

View workflow job for this annotation

GitHub Actions / Analyze (javascript)

The type or namespace name 'AppSettings' could not be found (are you missing a using directive or an assembly reference?)
: AuthorizationHandler<ApiAccessRequirement>
{
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);
}
}
}
}
}
97 changes: 97 additions & 0 deletions Messaging/FusionPBXAuth.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using Microsoft.AspNetCore.Http;

using NumberSearch.DataAccess.FusionPBX;

using Serilog;

using System;
using System.Threading.Tasks;

namespace Messaging
{
/// <summary>
/// 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.
/// </summary>
public static class FusionPBXAuth
{
/// <summary>
/// Pulls the raw bearer token out of the Authorization header, if present.
/// </summary>
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();
}

/// <summary>
/// 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.
/// </summary>
public static async Task<User> 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;
}
}

/// <summary>
/// Is the supplied FusionPBX user a real, enabled user?
/// </summary>
public static bool IsAuthenticated(this User user) => user.user_uuid != Guid.Empty;

/// <summary>
/// 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.
/// </summary>
public static async Task<bool> 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;
}
}
}
}
1 change: 1 addition & 0 deletions Messaging/Messaging.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@

<ItemGroup>
<ProjectReference Include="..\FirstCom\FirstCom.csproj" />
<ProjectReference Include="..\NumberSearch.DataAccess\NumberSearch.DataAccess.csproj" />
</ItemGroup>

</Project>
Loading
Loading