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
148 changes: 148 additions & 0 deletions dotnet/EcencyApi.Tests/DeviceAuthorizationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using EcencyApi.Handlers;
using Xunit;

namespace EcencyApi.Tests;

/// <summary>
/// /private-api/register-device and /private-api/detail-device act on a push
/// registration, which belongs to the account the code was issued for.
/// </summary>
public class DeviceAuthorizationTests
{
[Theory]
[InlineData("good-karma")]
// Hive names are lowercase, but the comparison must not hinge on that.
[InlineData("Good-Karma")]
[InlineData("GOOD-KARMA")]
public void TheCodesOwnAccountIsAccepted(string requested)
{
Assert.True(PrivateApi.IsOwnDeviceRequest("good-karma", requested));
}

[Theory]
[InlineData("someone-else")]
[InlineData("good-karm")]
[InlineData("good-karma2")]
[InlineData(" good-karma")]
[InlineData("good_karma")]
public void AnyOtherAccountIsRefused(string requested)
{
Assert.False(PrivateApi.IsOwnDeviceRequest("good-karma", requested));
}

[Theory]
[InlineData(null)]
[InlineData("")]
public void AMissingUsernameIsRefused(string? requested)
{
Assert.False(PrivateApi.IsOwnDeviceRequest("good-karma", requested));
}

[Theory]
[InlineData(null)]
[InlineData("")]
public void WithoutAValidatedCodeNothingIsAccepted(string? validated)
{
Assert.False(PrivateApi.IsOwnDeviceRequest(validated, "good-karma"));
Assert.False(PrivateApi.IsOwnDeviceRequest(validated, validated));
}
}

/// <summary>
/// The device handlers themselves: a code for one account cannot register or read a
/// device row under another, and nothing reaches upstream when it tries.
/// </summary>
[Collection("device-auth")]
public class DeviceHandlerTests : IDisposable
{
private readonly CurationDeskTestSupport.Recorder _upstream = new();

public DeviceHandlerTests()
{
// `code` "as:alice" validates as alice; anything else is invalid.
PrivateApi.DeviceValidateCode = body =>
{
var code = body["code"]?.GetValue<string>();
return Task.FromResult(code != null && code.StartsWith("as:", StringComparison.Ordinal) ? code[3..] : null);
};
PrivateApi.DeviceUpstream = (endpoint, method, payload) =>
_upstream.Handle(endpoint, method, Array.Empty<KeyValuePair<string, string>>(), payload);
}

public void Dispose()
{
PrivateApi.DeviceValidateCode = PrivateApi.ValidateCode;
PrivateApi.DeviceUpstream = (endpoint, method, payload) =>
EcencyApi.Infrastructure.ApiClient.ApiRequest(endpoint, method, null, payload);
}

private static string Register(string code, string? username) =>
username == null
? $$"""{"code":"{{code}}","token":"t1","system":"fcm-ios","allows_notify":1,"notify_types":[1]}"""
: $$"""{"code":"{{code}}","username":"{{username}}","token":"t1","system":"fcm-ios","allows_notify":1,"notify_types":[1]}""";

[Theory]
[InlineData("alice")]
[InlineData("Alice")]
public async Task RegisteringTheCodesOwnAccountIsForwarded(string username)
{
var ctx = CurationDeskTestSupport.Post("/private-api/register-device", Register("as:alice", username));
await PrivateApi.RegisterDevice(ctx);

Assert.Equal(200, ctx.Response.StatusCode);
var call = Assert.Single(_upstream.Calls);
Assert.Equal("rgstrmbldvc/", call.Endpoint);
Assert.Equal(HttpMethod.Post, call.Method);
Assert.Equal(username, call.Payload?["username"]?.GetValue<string>());
Assert.Equal("t1", call.Payload?["token"]?.GetValue<string>());
}

[Theory]
[InlineData("bob")]
[InlineData(null)]
public async Task RegisteringAnotherAccountIsRefused(string? username)
{
var ctx = CurationDeskTestSupport.Post("/private-api/register-device", Register("as:alice", username));
await PrivateApi.RegisterDevice(ctx);

Assert.Equal(403, ctx.Response.StatusCode);
Assert.Empty(_upstream.Calls);
}

[Fact]
public async Task RegisteringWithoutAValidCodeIsUnauthorized()
{
var ctx = CurationDeskTestSupport.Post("/private-api/register-device", Register("bogus", "alice"));
await PrivateApi.RegisterDevice(ctx);

Assert.Equal(401, ctx.Response.StatusCode);
Assert.Empty(_upstream.Calls);
}

[Fact]
public async Task ReadingTheCodesOwnDeviceIsForwarded()
{
var ctx = CurationDeskTestSupport.Post(
"/private-api/detail-device", """{"code":"as:alice","username":"alice","token":"t1"}""");
await PrivateApi.DetailDevice(ctx);

Assert.Equal(200, ctx.Response.StatusCode);
var call = Assert.Single(_upstream.Calls);
Assert.Equal("mbldvcdtl/alice/t1", call.Endpoint);
Assert.Equal(HttpMethod.Get, call.Method);
}

[Fact]
public async Task ReadingAnotherAccountsDeviceIsRefused()
{
var ctx = CurationDeskTestSupport.Post(
"/private-api/detail-device", """{"code":"as:alice","username":"bob","token":"t1"}""");
await PrivateApi.DetailDevice(ctx);

Assert.Equal(403, ctx.Response.StatusCode);
Assert.Empty(_upstream.Calls);
}
}

[CollectionDefinition("device-auth", DisableParallelization = true)]
public class DeviceAuthCollection { }
38 changes: 33 additions & 5 deletions dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ public static (string? Username, bool FullScope) ResolveNotificationsTarget(
string.Equals(requestedUser, validatedUsername, StringComparison.OrdinalIgnoreCase));
}

/// <summary>
/// Whether a device request may act on `requestedUsername`. A push registration
/// belongs to the account the code was issued for, so the device endpoints only
/// accept that account. Same case rule as ResolveNotificationsTarget.
/// </summary>
public static bool IsOwnDeviceRequest(string? validatedUsername, string? requestedUsername) =>
!string.IsNullOrEmpty(validatedUsername)
&& string.Equals(requestedUsername, validatedUsername, StringComparison.OrdinalIgnoreCase);

/// <summary>Signed-code validation for the device routes, replaceable for tests (no chain RPC).</summary>
internal static Func<JsonObject, Task<string?>> DeviceValidateCode = ValidateCode;

/// <summary>The device routes' upstream call, replaceable so tests can observe it.</summary>
internal static Func<string, HttpMethod, JsonNode?, Task<UpstreamResponse>> DeviceUpstream =
(endpoint, method, payload) => ApiClient.ApiRequest(endpoint, method, null, payload);

/// <summary>Header enotify reads the shared secret from.</summary>
public const string EnotifyInternalTokenHeader = "X-Ecency-Internal-Token";

Expand Down Expand Up @@ -193,37 +209,49 @@ public static async Task MarkNotifications(HttpContext ctx)
public static async Task RegisterDevice(HttpContext ctx)
{
var body = await ctx.ReadBody();
var authedUsername = await ValidateCode(body);
var authedUsername = await DeviceValidateCode(body);
if (string.IsNullOrEmpty(authedUsername))
{
await ctx.SendText(401, "Unauthorized");
return;
}

// Payload takes the fields from the request body, not the authed user.
if (!IsOwnDeviceRequest(authedUsername, body.Str("username")))
{
await ctx.SendText(403, "Forbidden");
return;
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
}

// Payload takes the fields from the request body; username matches the authed user.
var data = new JsonObject();
UserData1Helpers.CopyIfPresent(data, body, "username");
UserData1Helpers.CopyIfPresent(data, body, "token");
UserData1Helpers.CopyIfPresent(data, body, "system");
UserData1Helpers.CopyIfPresent(data, body, "allows_notify");
UserData1Helpers.CopyIfPresent(data, body, "notify_types");
await Upstream.Pipe(ApiClient.ApiRequest("rgstrmbldvc/", HttpMethod.Post, null, data), ctx);
await Upstream.Pipe(DeviceUpstream("rgstrmbldvc/", HttpMethod.Post, data), ctx);
}

// POST ^/private-api/detail-device$
public static async Task DetailDevice(HttpContext ctx)
{
var body = await ctx.ReadBody();
var authedUsername = await ValidateCode(body);
var authedUsername = await DeviceValidateCode(body);
if (string.IsNullOrEmpty(authedUsername))
{
await ctx.SendText(401, "Unauthorized");
return;
}

if (!IsOwnDeviceRequest(authedUsername, body.Str("username")))
{
await ctx.SendText(403, "Forbidden");
return;
}

var username = UserData1Helpers.TemplateOf(body, "username");
var token = UserData1Helpers.TemplateOf(body, "token");
await Upstream.Pipe(ApiClient.ApiRequest($"mbldvcdtl/{username}/{token}", HttpMethod.Get), ctx);
await Upstream.Pipe(DeviceUpstream($"mbldvcdtl/{username}/{token}", HttpMethod.Get, null), ctx);
}

// POST ^/private-api/images$
Expand Down
Loading