diff --git a/Tzkt.Api.Tests/Api/SettingsFixture.cs b/Tzkt.Api.Tests/Api/SettingsFixture.cs new file mode 100644 index 000000000..83dd5052a --- /dev/null +++ b/Tzkt.Api.Tests/Api/SettingsFixture.cs @@ -0,0 +1,41 @@ +using System; +using System.Net.Http; +using Dynamic.Json; + +namespace Tzkt.Api.Tests.Api +{ + public class SettingsFixture : IDisposable + { + static readonly object Crit = new(); + + public HttpClient Client { get; } + public string Baker { get; } + public string Delegator { get; } + public string Originator { get; } + public int Cycle { get; } + + public SettingsFixture() + { + lock (Crit) + { + var settings = DJson.Read("../../../Api/settings.json"); + + Client = new HttpClient() + { + BaseAddress = new Uri(settings.Url) + }; + + Baker = settings.Baker; + Delegator = settings.Delegator; + Originator = settings.Originator; + Cycle = settings.Cycle; + } + } + + public void Dispose() + { + Client.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/Tzkt.Api.Tests/Api/TestAccountsQueries.cs b/Tzkt.Api.Tests/Api/TestAccountsQueries.cs new file mode 100644 index 000000000..c29d670e8 --- /dev/null +++ b/Tzkt.Api.Tests/Api/TestAccountsQueries.cs @@ -0,0 +1,132 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Dynamic.Json; +using Dynamic.Json.Extensions; +using Xunit; + +namespace Tzkt.Api.Tests.Api +{ + public class TestAccountsQueries : IClassFixture + { + readonly SettingsFixture Settings; + readonly HttpClient Client; + + public TestAccountsQueries(SettingsFixture settings) + { + Settings = settings; + Client = settings.Client; + } + + [Fact] + public async Task TestAccountsCount() + { + var res = await Client.GetJsonAsync("/v1/accounts/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestAccounts() + { + var res = await Client.GetJsonAsync("/v1/accounts"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestUsers() + { + var res = await Client.GetJsonAsync("/v1/accounts?type=user"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestDelegates() + { + var res = await Client.GetJsonAsync("/v1/accounts?type=delegate"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestContracts() + { + var res = await Client.GetJsonAsync("/v1/accounts?type=contract"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestGhosts() + { + var res = await Client.GetJsonAsync("/v1/accounts?type=ghost"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestAccountByAddress() + { + var res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}"); + + Assert.True(res is DJsonObject); + } + + [Fact] + public async Task TestAccountContracts() + { + var res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Originator}/contracts"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestAccountDelegators() + { + var res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/delegators"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestAccountOperations() + { + var res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/operations"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestCounter() + { + var res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/counter"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestBalance() + { + var res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/balance"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestBalanceAtLevel() + { + var res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/balance_history/10"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestBalanceHistory() + { + var res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/balance_history"); + + Assert.True(res is DJsonArray); + } + } +} diff --git a/Tzkt.Api.Tests/Api/TestBlocksQueries.cs b/Tzkt.Api.Tests/Api/TestBlocksQueries.cs new file mode 100644 index 000000000..745711c61 --- /dev/null +++ b/Tzkt.Api.Tests/Api/TestBlocksQueries.cs @@ -0,0 +1,58 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Dynamic.Json; +using Dynamic.Json.Extensions; +using Xunit; + +namespace Tzkt.Api.Tests.Api +{ + public class TestBlocksQueries : IClassFixture + { + readonly HttpClient Client; + + public TestBlocksQueries(SettingsFixture settings) + { + Client = settings.Client; + } + + [Fact] + public async Task TestBlocksCount() + { + var res = await Client.GetJsonAsync("/v1/blocks/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestBlocks() + { + var res = await Client.GetJsonAsync("/v1/blocks"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestBlockByLevel() + { + var res = await Client.GetJsonAsync("/v1/blocks/10"); + + Assert.True(res is DJsonObject); + } + + [Fact] + public async Task TestBlockOperations() + { + var res = await Client.GetJsonAsync("/v1/blocks/10?operations=true"); + + Assert.True(res is DJsonObject); + } + + [Fact] + public async Task TestBlockQuotes() + { + var res = await Client.GetJsonAsync("/v1/blocks/10?quote=usd"); + + Assert.True(res is DJsonObject); + } + } +} diff --git a/Tzkt.Api.Tests/Api/TestOperationsQueries.cs b/Tzkt.Api.Tests/Api/TestOperationsQueries.cs new file mode 100644 index 000000000..14a556732 --- /dev/null +++ b/Tzkt.Api.Tests/Api/TestOperationsQueries.cs @@ -0,0 +1,274 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Dynamic.Json; +using Dynamic.Json.Extensions; +using Xunit; + +namespace Tzkt.Api.Tests.Api +{ + public class TestOperationsQueries : IClassFixture + { + readonly HttpClient Client; + + public TestOperationsQueries(SettingsFixture settings) + { + Client = settings.Client; + } + + [Fact] + public async Task TestEndorsements() + { + var res = await Client.GetJsonAsync("/v1/operations/endorsements"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestEndorsementsCount() + { + var res = await Client.GetJsonAsync("/v1/operations/endorsements/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestBallots() + { + var res = await Client.GetJsonAsync("/v1/operations/ballots"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestBallotsCount() + { + var res = await Client.GetJsonAsync("/v1/operations/ballots/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestProposals() + { + var res = await Client.GetJsonAsync("/v1/operations/proposals"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestProposalsCount() + { + var res = await Client.GetJsonAsync("/v1/operations/proposals/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestActivations() + { + var res = await Client.GetJsonAsync("/v1/operations/activations"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestActivationsCount() + { + var res = await Client.GetJsonAsync("/v1/operations/activations/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestDoubleBaking() + { + var res = await Client.GetJsonAsync("/v1/operations/double_baking"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestDoubleBakingCount() + { + var res = await Client.GetJsonAsync("/v1/operations/double_baking/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestDoubleEndorsing() + { + var res = await Client.GetJsonAsync("/v1/operations/double_endorsing"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestDoubleEndorsingCount() + { + var res = await Client.GetJsonAsync("/v1/operations/double_endorsing/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestNonceRevelations() + { + var res = await Client.GetJsonAsync("/v1/operations/nonce_revelations"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestNonceRevelationsCount() + { + var res = await Client.GetJsonAsync("/v1/operations/nonce_revelations/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestDelegations() + { + var res = await Client.GetJsonAsync("/v1/operations/delegations"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestDelegationsCount() + { + var res = await Client.GetJsonAsync("/v1/operations/delegations/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestOriginations() + { + var res = await Client.GetJsonAsync("/v1/operations/originations"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestOriginationsCount() + { + var res = await Client.GetJsonAsync("/v1/operations/originations/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestTransactions() + { + var res = await Client.GetJsonAsync("/v1/operations/transactions"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestTransactionsCount() + { + var res = await Client.GetJsonAsync("/v1/operations/transactions/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestReveals() + { + var res = await Client.GetJsonAsync("/v1/operations/reveals"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestRevealsCount() + { + var res = await Client.GetJsonAsync("/v1/operations/reveals/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestRegisterConstants() + { + var res = await Client.GetJsonAsync("/v1/operations/register_constants"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestSetDepositsLimitsCount() + { + var res = await Client.GetJsonAsync("/v1/operations/set_deposits_limits/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestSetDepositsLimits() + { + var res = await Client.GetJsonAsync("/v1/operations/set_deposits_limits"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestRegisterConstantsCount() + { + var res = await Client.GetJsonAsync("/v1/operations/register_constants/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestMigrations() + { + var res = await Client.GetJsonAsync("/v1/operations/migrations"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestMigrationsCount() + { + var res = await Client.GetJsonAsync("/v1/operations/migrations/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestRevelationPenalties() + { + var res = await Client.GetJsonAsync("/v1/operations/revelation_penalties"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestRevelationPenaltiesCount() + { + var res = await Client.GetJsonAsync("/v1/operations/revelation_penalties/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestBaking() + { + var res = await Client.GetJsonAsync("/v1/operations/baking"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestBakingCount() + { + var res = await Client.GetJsonAsync("/v1/operations/baking/count"); + + Assert.True(res is DJsonValue); + } + } +} diff --git a/Tzkt.Api.Tests/Api/TestRewardsQueries.cs b/Tzkt.Api.Tests/Api/TestRewardsQueries.cs new file mode 100644 index 000000000..e189d4c8f --- /dev/null +++ b/Tzkt.Api.Tests/Api/TestRewardsQueries.cs @@ -0,0 +1,84 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Dynamic.Json; +using Dynamic.Json.Extensions; +using Xunit; + +namespace Tzkt.Api.Tests.Api +{ + public class TestRewardsQueries : IClassFixture + { + readonly HttpClient Client; + readonly SettingsFixture Settings; + + public TestRewardsQueries(SettingsFixture settings) + { + Client = settings.Client; + Settings = settings; + } + + [Fact] + public async Task TestBakerRewardsCount() + { + var res = await Client.GetJsonAsync($"/v1/rewards/bakers/{Settings.Baker}/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestBakerRewards() + { + var res = await Client.GetJsonAsync($"/v1/rewards/bakers/{Settings.Baker}"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestBakerRewardsByCycle() + { + var res = await Client.GetJsonAsync($"/v1/rewards/bakers/{Settings.Baker}/{Settings.Cycle}"); + + Assert.True(res is DJsonObject); + } + + [Fact] + public async Task TestDelegatorRewardsCount() + { + var res = await Client.GetJsonAsync($"/v1/rewards/delegators/{Settings.Baker}/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestDelegatorRewards() + { + var res = await Client.GetJsonAsync($"/v1/rewards/delegators/{Settings.Delegator}"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestDelegatorRewardsByCycle() + { + var res = await Client.GetJsonAsync($"/v1/rewards/delegators/{Settings.Delegator}/{Settings.Cycle}"); + + Assert.True(res is DJsonObject); + } + + [Fact] + public async Task TestRewardSplit() + { + var res = await Client.GetJsonAsync($"/v1/rewards/split/{Settings.Baker}/{Settings.Cycle}"); + + Assert.True(res is DJsonObject); + } + + [Fact] + public async Task TestRewardSplitDelegator() + { + var res = await Client.GetJsonAsync($"/v1/rewards/split/{Settings.Baker}/{Settings.Cycle}/{Settings.Delegator}"); + + Assert.True(res is DJsonObject); + } + } +} diff --git a/Tzkt.Api.Tests/Api/TestRightsQueries.cs b/Tzkt.Api.Tests/Api/TestRightsQueries.cs new file mode 100644 index 000000000..1aa9561ec --- /dev/null +++ b/Tzkt.Api.Tests/Api/TestRightsQueries.cs @@ -0,0 +1,50 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Dynamic.Json; +using Dynamic.Json.Extensions; +using Xunit; + +namespace Tzkt.Api.Tests.Api +{ + public class TestRightsQueries : IClassFixture + { + readonly HttpClient Client; + + public TestRightsQueries(SettingsFixture settings) + { + Client = settings.Client; + } + + [Fact] + public async Task TestRightsCount() + { + var res = await Client.GetJsonAsync("/v1/rights/count"); + + Assert.True(res is DJsonValue); + } + + [Fact] + public async Task TestRights() + { + var res = await Client.GetJsonAsync("/v1/rights"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestBakingRights() + { + var res = await Client.GetJsonAsync("/v1/rights?type=baking"); + + Assert.True(res is DJsonArray); + } + + [Fact] + public async Task TestEndorsingRights() + { + var res = await Client.GetJsonAsync("/v1/rights?type=endorsing"); + + Assert.True(res is DJsonArray); + } + } +} diff --git a/Tzkt.Api.Tests/Api/settings.json b/Tzkt.Api.Tests/Api/settings.json new file mode 100644 index 000000000..e40f078f8 --- /dev/null +++ b/Tzkt.Api.Tests/Api/settings.json @@ -0,0 +1,7 @@ +{ + "url": "https://staging.api.tzkt.io/", + "baker": "tz1S7GgVV4FPEGUVzepKBwx22DyNikdpa4X6", + "originator": "KT1Aq4wWmVanpQhq4TTfjZXB5AjFpx15iQMM", + "delegator": "KT1GbVHbEXqEnu18FaobkExQgAJbUfoWbcWR", + "cycle": 10 +} diff --git a/Tzkt.Api.Tests/Api/settings_ithacanet.json b/Tzkt.Api.Tests/Api/settings_ithacanet.json new file mode 100644 index 000000000..58c9a2d18 --- /dev/null +++ b/Tzkt.Api.Tests/Api/settings_ithacanet.json @@ -0,0 +1,7 @@ +{ + "url": "https://api.ithacanet.tzkt.io/", + "baker": "tz1aWXP237BLwNHJcCD4b3DutCevhqq2T1Z9", + "originator": "tz1VBLpuDKMoJuHRLZ4HrCgRuiLpEr7zZx2E", + "delegator": "tz1ipXPKv9UAfiz5Wtpq19WaoK7TfhLzrwX9", + "cycle": 20 +} diff --git a/Tzkt.Api.Tests/Auth/AuthServiceTests.cs b/Tzkt.Api.Tests/Auth/AuthServiceTests.cs new file mode 100644 index 000000000..ba66be2ab --- /dev/null +++ b/Tzkt.Api.Tests/Auth/AuthServiceTests.cs @@ -0,0 +1,192 @@ + using System; +using System.Linq; + using System.Threading.Tasks; + using Microsoft.Extensions.Configuration; +using Netezos.Encoding; +using Netezos.Keys; +using Netezos.Utils; +using Tzkt.Api.Services.Auth; +using Xunit; + +namespace Tzkt.Api.Tests.Auth; + +public class AuthServiceTests +{ + [Fact] + public void PubKeyAuthTest() + { + string? error; + string? expectedError; + + var configuration = new ConfigurationBuilder() + .AddJsonFile("Auth/Samples/PubKeyAuthSample.json") + .Build(); + + var auth = new PubKeyAuth(configuration); + var config = configuration.GetAuthConfig(); + var key = Key.FromBase58(configuration.GetSection("PrivKey").Value); + + foreach (var credentials in config.Users) + { + var rights = new AccessRights() + { + Table = "WrongTable", + Access = Access.Write + }; + + var headers = new AuthHeaders(); + + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = "The X-TZKT-USER header is required"; + Assert.Equal(expectedError, error); + + headers.User = "wrongName"; + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = "The X-TZKT-NONCE header is required"; + Assert.Equal(expectedError, error); + + headers.Nonce = (long)(DateTime.UtcNow.AddSeconds(-config.NonceLifetime - 1) - DateTime.UnixEpoch).TotalMilliseconds; + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = "The X-TZKT-SIGNATURE header is required"; + Assert.Equal(expectedError, error); + + headers.Signature = "wrongSignature"; + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = $"User {headers.User} doesn't exist"; + Assert.Equal(expectedError, error); + + headers.User = credentials.Name; + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = "Nonce too old."; + Assert.StartsWith(expectedError, error); + + headers.Nonce = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalMilliseconds; + + if (credentials.Rights != null) + { + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = $"User {headers.User} doesn't have required permissions. {rights.Table} required."; + Assert.StartsWith(expectedError, error); + + rights.Table = credentials.Rights.FirstOrDefault(x => x.Section == null)?.Table; + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = $"User {headers.User} doesn't have required permissions. {Access.Write} required."; + Assert.StartsWith(expectedError, error); + + rights.Section = "wrongSection"; + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = $"User {headers.User} doesn't have required permissions. {rights.Section} required."; + Assert.StartsWith(expectedError, error); + + rights.Section = credentials.Rights.FirstOrDefault(x => x.Section != null)?.Section; + rights.Access = Access.Write; + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = $"User {headers.User} doesn't have required permissions. {rights.Access} required for section {rights.Section}."; + Assert.StartsWith(expectedError, error); + } + + rights.Access = Access.Read; + + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = "Invalid signature"; + Assert.Equal(expectedError, error); + + headers.Signature = key.Sign($"{headers.Nonce}").ToBase58(); + + Assert.True(auth.TryAuthenticate(headers, rights, out error)); + + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = $"Nonce {headers.Nonce} has already been used"; + Assert.Equal(expectedError, error); + + Task.Delay(10); + string? json = null; + headers.Nonce = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalMilliseconds; + Assert.False(auth.TryAuthenticate(headers, rights, json, out error)); + expectedError = "Request body is empty"; + Assert.Equal(expectedError, error); + + json = "{\"test\": \"test\"}"; + var hash = Hex.Convert(Blake2b.GetDigest(Utf8.Parse(json))); + headers.Signature = key.Sign($"{headers.Nonce}{hash}").ToBase58(); + Assert.True(auth.TryAuthenticate(headers, rights, json, out error)); + } + } + + [Fact] + public void PasswordAuthTest() + { + string? error; + string? expectedError; + + var configuration = new ConfigurationBuilder() + .AddJsonFile("Auth/Samples/PasswordAuthSample.json") + .Build(); + + var auth = new PasswordAuth(configuration); + var config = configuration.GetAuthConfig(); + + foreach (var credentials in config.Users) + { + var rights = new AccessRights() + { + Table = "WrongTable", + Section = "WrongSection", + Access = Access.Write + }; + + var headers = new AuthHeaders(); + + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = "The X-TZKT-USER header is required"; + Assert.Equal(expectedError, error); + + headers.User = "wrongName"; + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = "The X-TZKT-PASSWORD header is required"; + Assert.Equal(expectedError, error); + + headers.Password = "wrongPassword"; + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = $"User {headers.User} doesn't exist"; + Assert.Equal(expectedError, error); + + headers.User = credentials.Name; + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = "Invalid password"; + Assert.StartsWith(expectedError, error); + + headers.Password = credentials.Password; + + if (credentials.Rights != null) + { + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = $"User {headers.User} doesn't have required permissions. {rights.Table} required."; + Assert.StartsWith(expectedError, error); + + rights.Table = credentials.Rights.FirstOrDefault()?.Table; + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = $"User {headers.User} doesn't have required permissions. {rights.Section} required."; + Assert.StartsWith(expectedError, error); + + rights.Section = credentials.Rights.FirstOrDefault()?.Section; + Assert.False(auth.TryAuthenticate(headers, rights, out error)); + expectedError = $"User {headers.User} doesn't have required permissions. {Access.Write} required."; + Assert.StartsWith(expectedError, error); + } + + rights.Access = Access.Read; + Assert.True(auth.TryAuthenticate(headers, rights, out error)); + + string? json = null; + Assert.False(auth.TryAuthenticate(headers, rights, json, out error)); + expectedError = "Request body is empty"; + Assert.Equal(expectedError, error); + + json = "{\"test\": \"test\"}"; + Assert.True(auth.TryAuthenticate(headers, rights, json, out error)); + } + + + } +} \ No newline at end of file diff --git a/Tzkt.Api.Tests/Auth/Samples/PasswordAuthSample.json b/Tzkt.Api.Tests/Auth/Samples/PasswordAuthSample.json new file mode 100644 index 000000000..dc52b2e4c --- /dev/null +++ b/Tzkt.Api.Tests/Auth/Samples/PasswordAuthSample.json @@ -0,0 +1,37 @@ +{ + "Authentication": { + "Method": "Password", + "NonceLifetime": 1000, + "Users": [ + { + "Name": "full", + "Password": "testPassword123F$#&VN", + }, + { + "Name": "alexey", + "Password": "testPassword123F$#&VN", + "Rights": [ + { + "Table": "Accounts", + "Access": "Read" + }, + { + "Table": "Accounts", + "Section": "AccountsSectionTwo", + "Access": "Write" + }, + { + "Table": "Operations", + "Section": "OperationsSectionOne", + "Access": "Read" + }, + { + "Table": "Operations", + "Section": "AccountsSectionTwo", + "Access": "Write" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/Tzkt.Api.Tests/Auth/Samples/PubKeyAuthSample.json b/Tzkt.Api.Tests/Auth/Samples/PubKeyAuthSample.json new file mode 100644 index 000000000..437beb31e --- /dev/null +++ b/Tzkt.Api.Tests/Auth/Samples/PubKeyAuthSample.json @@ -0,0 +1,38 @@ +{ + "Authentication": { + "Method": "PubKey", + "NonceLifetime": 1000, + "Users": [ + { + "Name": "full", + "PubKey": "edpkvJbQThvxUT9L4EuF9SQtAnsF6wLXTJ2J435Jvds6aaK5Yw725N" + }, + { + "Name": "alexey", + "PubKey": "edpkvJbQThvxUT9L4EuF9SQtAnsF6wLXTJ2J435Jvds6aaK5Yw725N", + "Rights": [ + { + "Table": "Accounts", + "Access": "Read" + }, + { + "Table": "Accounts", + "Section": "AccountsSectionTwo", + "Access": "Read" + }, + { + "Table": "Operations", + "Section": "OperationsSectionOne", + "Access": "Read" + }, + { + "Table": "Operations", + "Section": "AccountsSectionTwo", + "Access": "Write" + } + ] + } + ] + }, + "PrivKey": "edsk2zaL631nxAfvG7xHAGf6ne1atpKuKY9qh3iv6DburheLovKuTW" +} \ No newline at end of file diff --git a/Tzkt.Api.Tests/Tzkt.Api.Tests.csproj b/Tzkt.Api.Tests/Tzkt.Api.Tests.csproj new file mode 100644 index 000000000..1a889ff59 --- /dev/null +++ b/Tzkt.Api.Tests/Tzkt.Api.Tests.csproj @@ -0,0 +1,43 @@ + + + + net5.0 + enable + + false + + 10 + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + true + PreserveNewest + PreserveNewest + + + true + PreserveNewest + PreserveNewest + + + + + + + + diff --git a/Tzkt.Api/Controllers/MetadataController.cs b/Tzkt.Api/Controllers/MetadataController.cs index df4b1f918..00f07d0e5 100644 --- a/Tzkt.Api/Controllers/MetadataController.cs +++ b/Tzkt.Api/Controllers/MetadataController.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -33,7 +32,14 @@ public async Task> GetStateMetadata( [FromHeader] AuthHeaders headers, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "AppState", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetStateMetadata(section)); @@ -41,17 +47,25 @@ public async Task> GetStateMetadata( [HttpPost("state")] public async Task> UpdateStateMetadata( - [FromHeader] AuthHeaders headers) + [FromHeader] AuthHeaders headers, + string section = null) { try { + var rights = new AccessRights() + { + Table = "AppState", + Section = section, + Access = Access.Write + }; + var body = await Request.Body.ReadAsStringAsync(); - if (!Auth.TryAuthenticate(headers, body, out var error)) + if (!Auth.TryAuthenticate(headers, rights,body, out var error)) return Unauthorized(error); var metadata = JsonSerializer.Deserialize(body); - return Ok(await Metadata.UpdateStateMetadata(metadata)); + return Ok(await Metadata.UpdateStateMetadata(metadata, section)); } catch (JsonException) { @@ -67,7 +81,14 @@ public async Task> GetAccountMetadata( [Address] string address, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "Accounts", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetAccountMetadata(address, section)); @@ -81,7 +102,14 @@ public async Task>>> GetAccountM [Range(0, 10000)] int limit = 100, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "Accounts", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetAccountMetadata(metadata, offset, limit, section)); @@ -89,20 +117,28 @@ public async Task>>> GetAccountM [HttpPost("accounts")] public async Task>>> UpdateAccountMetadata( - [FromHeader] AuthHeaders headers) + [FromHeader] AuthHeaders headers, + string section = null) { try { + var rights = new AccessRights() + { + Table = "Accounts", + Section = section, + Access = Access.Write + }; + var body = await Request.Body.ReadAsStringAsync(); - if (!Auth.TryAuthenticate(headers, body, out var error)) + if (!Auth.TryAuthenticate(headers, rights, body, out var error)) return Unauthorized(error); var metadata = JsonSerializer.Deserialize>>(body); if (metadata.Any(x => !Regex.IsMatch(x.Key, "^(tz1|tz2|tz3|KT1)[0-9A-Za-z]{33}$"))) return new BadRequest("body", "Invalid account address"); - return Ok(await Metadata.UpdateAccountMetadata(metadata)); + return Ok(await Metadata.UpdateAccountMetadata(metadata, section)); } catch (JsonException) { @@ -118,7 +154,14 @@ public async Task> GetProposalMetadata( [ProtocolHash] string hash, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "Proposals", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetProposalMetadata(hash, section)); @@ -132,7 +175,14 @@ public async Task>>> GetProposal [Range(0, 10000)] int limit = 100, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "Proposals", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetProposalMetadata(metadata, offset, limit, section)); @@ -140,20 +190,28 @@ public async Task>>> GetProposal [HttpPost("proposals")] public async Task>>> UpdateProposalMetadata( - [FromHeader] AuthHeaders headers) + [FromHeader] AuthHeaders headers, + string section = null) { try { + var rights = new AccessRights() + { + Table = "Proposals", + Section = section, + Access = Access.Write + }; + var body = await Request.Body.ReadAsStringAsync(); - if (!Auth.TryAuthenticate(headers, body, out var error)) + if (!Auth.TryAuthenticate(headers, rights, body, out var error)) return Unauthorized(error); var metadata = JsonSerializer.Deserialize>>(body); if (metadata.Any(x => !Regex.IsMatch(x.Key, "^P[0-9A-Za-z]{50}$"))) return BadRequest("Invalid proposal hash"); - return Ok(await Metadata.UpdatProposalMetadata(metadata)); + return Ok(await Metadata.UpdatProposalMetadata(metadata, section)); } catch (JsonException) { @@ -169,7 +227,14 @@ public async Task> GetProtocolMetadata( [ProtocolHash] string hash, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "Protocols", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetProtocolMetadata(hash, section)); @@ -183,7 +248,14 @@ public async Task>>> GetProtocol [Range(0, 10000)] int limit = 100, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "Protocols", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetProtocolMetadata(metadata, offset, limit, section)); @@ -191,20 +263,28 @@ public async Task>>> GetProtocol [HttpPost("protocols")] public async Task>>> UpdateProtocolMetadata( - [FromHeader] AuthHeaders headers) + [FromHeader] AuthHeaders headers, + string section = null) { try { + var rights = new AccessRights() + { + Table = "Protocols", + Section = section, + Access = Access.Write + }; + var body = await Request.Body.ReadAsStringAsync(); - if (!Auth.TryAuthenticate(headers, body, out var error)) + if (!Auth.TryAuthenticate(headers, rights, body, out var error)) return Unauthorized(error); var metadata = JsonSerializer.Deserialize>>(body); if (metadata.Any(x => !Regex.IsMatch(x.Key, "^P[0-9A-Za-z]{50}$"))) return BadRequest("Invalid protocol hash"); - return Ok(await Metadata.UpdatProtocolMetadata(metadata)); + return Ok(await Metadata.UpdatProtocolMetadata(metadata, section)); } catch (JsonException) { @@ -220,7 +300,14 @@ public async Task> GetSoftwareMetadata( [Hex(8)] string shortHash, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "Software", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetSoftwareMetadata(shortHash, section)); @@ -234,7 +321,14 @@ public async Task>>> GetSoftware [Range(0, 10000)] int limit = 100, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "Software", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetSoftwareMetadata(metadata, offset, limit, section)); @@ -242,19 +336,27 @@ public async Task>>> GetSoftware [HttpPost("software")] public async Task>>> UpdateSoftwareMetadata( - [FromHeader] AuthHeaders headers) + [FromHeader] AuthHeaders headers, + string section = null) { try { + var rights = new AccessRights() + { + Table = "Software", + Section = section, + Access = Access.Write + }; + var body = await Request.Body.ReadAsStringAsync(); - if (!Auth.TryAuthenticate(headers, body, out var error)) + if (!Auth.TryAuthenticate(headers, rights, body, out var error)) return Unauthorized(error); var metadata = JsonSerializer.Deserialize>>(body); if (metadata.Any(x => !Regex.IsMatch(x.Key, "^[0-9a-f]{8}$"))) return BadRequest("Invalid software short hash"); - return Ok(await Metadata.UpdateSoftwareMetadata(metadata)); + return Ok(await Metadata.UpdateSoftwareMetadata(metadata, section)); } catch (JsonException) { @@ -270,7 +372,14 @@ public async Task> GetConstantMetadata( [ExpressionHash] string address, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "Constants", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetConstantMetadata(address, section)); @@ -284,7 +393,14 @@ public async Task>>> GetConstant [Range(0, 10000)] int limit = 100, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "Constants", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetConstantMetadata(metadata, offset, limit, section)); @@ -292,19 +408,27 @@ public async Task>>> GetConstant [HttpPost("constants")] public async Task>>> UpdateConstantMetadata( - [FromHeader] AuthHeaders headers) + [FromHeader] AuthHeaders headers, + string section = null) { try { + var rights = new AccessRights() + { + Table = "Constants", + Section = section, + Access = Access.Write + }; + var body = await Request.Body.ReadAsStringAsync(); - if (!Auth.TryAuthenticate(headers, body, out var error)) + if (!Auth.TryAuthenticate(headers, rights,body, out var error)) return Unauthorized(error); var metadata = JsonSerializer.Deserialize>>(body); if (metadata.Any(x => !Regex.IsMatch(x.Key, "^expr[0-9A-Za-z]{50}$"))) return BadRequest("Invalid expression hash"); - return Ok(await Metadata.UpdateConstantMetadata(metadata)); + return Ok(await Metadata.UpdateConstantMetadata(metadata, section)); } catch (JsonException) { @@ -320,7 +444,14 @@ public async Task> GetBlockMetadata( [Min(0)] int level, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "Blocks", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetBlockMetadata(level, section)); @@ -334,7 +465,14 @@ public async Task>>> GetBlockMetada [Range(0, 10000)] int limit = 100, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "Blocks", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetBlockMetadata(metadata, offset, limit, section)); @@ -342,16 +480,24 @@ public async Task>>> GetBlockMetada [HttpPost("blocks")] public async Task>>> UpdateBlockMetadata( - [FromHeader] AuthHeaders headers) + [FromHeader] AuthHeaders headers, + string section = null) { try { + var rights = new AccessRights() + { + Table = "Blocks", + Section = section, + Access = Access.Write + }; + var body = await Request.Body.ReadAsStringAsync(); - if (!Auth.TryAuthenticate(headers, body, out var error)) + if (!Auth.TryAuthenticate(headers, rights, body, out var error)) return Unauthorized(error); var metadata = JsonSerializer.Deserialize>>(body); - return Ok(await Metadata.UpdateBlockMetadata(metadata)); + return Ok(await Metadata.UpdateBlockMetadata(metadata, section)); } catch (JsonException) { @@ -367,7 +513,14 @@ public async Task> GetTokenMetadata( [Min(0)] int id, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "Tokens", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetTokenMetadata(id, section)); @@ -381,7 +534,14 @@ public async Task>>> GetTokenMetada [Range(0, 10000)] int limit = 100, string section = null) { - if (!Auth.TryAuthenticate(headers, out var error)) + var rights = new AccessRights() + { + Table = "Tokens", + Section = section, + Access = Access.Read + }; + + if (!Auth.TryAuthenticate(headers, rights, out var error)) return Unauthorized(error); return Ok(await Metadata.GetTokenMetadata(metadata, offset, limit, section)); @@ -389,16 +549,24 @@ public async Task>>> GetTokenMetada [HttpPost("tokens")] public async Task>>> UpdateTokenMetadata( - [FromHeader] AuthHeaders headers) + [FromHeader] AuthHeaders headers, + string section = null) { try { + var rights = new AccessRights() + { + Table = "Tokens", + Section = section, + Access = Access.Write + }; + var body = await Request.Body.ReadAsStringAsync(); - if (!Auth.TryAuthenticate(headers, body, out var error)) + if (!Auth.TryAuthenticate(headers, rights, body, out var error)) return Unauthorized(error); var metadata = JsonSerializer.Deserialize>>(body); - return Ok(await Metadata.UpdateTokenMetadata(metadata)); + return Ok(await Metadata.UpdateTokenMetadata(metadata, section)); } catch (JsonException) { diff --git a/Tzkt.Api/Program.cs b/Tzkt.Api/Program.cs index 9a556007c..5f2faad2c 100644 --- a/Tzkt.Api/Program.cs +++ b/Tzkt.Api/Program.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; - +using Tzkt.Api.Services.Auth; using Tzkt.Data; namespace Tzkt.Api @@ -17,7 +17,7 @@ public class Program { public static void Main(string[] args) { - CreateHostBuilder(args).Build().Init().Run(); + CreateHostBuilder(args).Build().Check().Init().Run(); } public static IHostBuilder CreateHostBuilder(string[] args) => @@ -93,5 +93,13 @@ public static IHost Init(this IHost host, int attempt = 0) return host.Init(++attempt); } } + + public static IHost Check(this IHost host) + { + using var scope = host.Services.CreateScope(); + var config = scope.ServiceProvider.GetRequiredService(); + config.ValidateAuthConfig(); + return host; + } } } diff --git a/Tzkt.Api/Repositories/MetadataRepository.cs b/Tzkt.Api/Repositories/MetadataRepository.cs index 39d4a13f3..a53617dc5 100644 --- a/Tzkt.Api/Repositories/MetadataRepository.cs +++ b/Tzkt.Api/Repositories/MetadataRepository.cs @@ -132,58 +132,55 @@ void PrependSection(List<(JsonPath[], TValue)> list) return rows.Select(row => new MetadataUpdate { Key = row.key, - Section = section, Metadata = row.metadata }); } #endregion #region update - public async Task UpdateStateMetadata(MetadataUpdate metadata) + public async Task UpdateStateMetadata(MetadataUpdate metadata, string section) => (await Update("AppState", "Id", "integer", new List> { new() { Key = -1, - Section = metadata.Section, Metadata = metadata.Metadata } - })) + }, section)) .Select(x => new MetadataUpdate { - Section = x.Section, Metadata = x.Metadata }) .FirstOrDefault(); - public Task>> UpdateAccountMetadata(List> metadata) - => Update("Accounts", "Address", "character(36)", metadata); + public Task>> UpdateAccountMetadata(List> metadata, string section) + => Update("Accounts", "Address", "character(36)", metadata, section); - public Task>> UpdatProposalMetadata(List> metadata) - => Update("Proposals", "Hash", "character(51)", metadata); + public Task>> UpdatProposalMetadata(List> metadata, string section) + => Update("Proposals", "Hash", "character(51)", metadata, section); - public Task>> UpdatProtocolMetadata(List> metadata) - => Update("Protocols", "Hash", "character(51)", metadata); + public Task>> UpdatProtocolMetadata(List> metadata, string section) + => Update("Protocols", "Hash", "character(51)", metadata, section); - public Task>> UpdateSoftwareMetadata(List> metadata) - => Update("Software", "ShortHash", "character(8)", metadata); + public Task>> UpdateSoftwareMetadata(List> metadata, string section) + => Update("Software", "ShortHash", "character(8)", metadata, section); - public Task>> UpdateConstantMetadata(List> metadata) - => Update("RegisterConstantOps", "Address", "character(54)", metadata); + public Task>> UpdateConstantMetadata(List> metadata, string section) + => Update("RegisterConstantOps", "Address", "character(54)", metadata, section); - public Task>> UpdateBlockMetadata(List> metadata) - => Update("Blocks", "Level", "integer", metadata); + public Task>> UpdateBlockMetadata(List> metadata, string section) + => Update("Blocks", "Level", "integer", metadata, section); - public Task>> UpdateTokenMetadata(List> metadata) - => Update("Tokens", "Id", "integer", metadata); + public Task>> UpdateTokenMetadata(List> metadata, string section) + => Update("Tokens", "Id", "integer", metadata, section); - async Task>> Update(string table, string keyColumn, string keyType, List> metadata) + async Task>> Update(string table, string keyColumn, string keyType, List> metadata, string section) { var res = new List>(metadata.Count); using var db = GetConnection(); foreach (var meta in metadata) { - var value = (meta.Section == null, meta.Metadata == null) switch + var value = (section == null, meta.Metadata == null) switch { (false, false) => @"jsonb_set(COALESCE(""Metadata"", '{}'), @section::text[], @metadata::jsonb)", // set section (false, true) => @"NULLIF(COALESCE(""Metadata"", '{}') #- @section::text[], '{}')", // remove section @@ -195,7 +192,7 @@ async Task>> Update(string table, string keyColumn, st UPDATE ""{table}"" SET ""Metadata"" = {value} WHERE ""{keyColumn}"" = @key::{keyType}", - new { key = meta.Key, metadata = (string)meta.Metadata, section = new[] { meta.Section } }); + new { key = meta.Key, metadata = (string)meta.Metadata, section = new[] { section } }); if (rows > 0) res.Add(meta); diff --git a/Tzkt.Api/Services/Auth/AuthConfig.cs b/Tzkt.Api/Services/Auth/AuthConfig.cs index c883e84c8..8d7fffa5a 100644 --- a/Tzkt.Api/Services/Auth/AuthConfig.cs +++ b/Tzkt.Api/Services/Auth/AuthConfig.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using Microsoft.Extensions.Configuration; +using Netezos.Keys; namespace Tzkt.Api.Services.Auth { @@ -7,14 +8,48 @@ public class AuthConfig { public AuthMethod Method { get; set; } = AuthMethod.None; public int NonceLifetime { get; set; } = 10; - public Dictionary Credentials { get; set; } = new(); + public List Users { get; set; } = new(); } public static class AuthConfigExt { public static AuthConfig GetAuthConfig(this IConfiguration config) { - return config.GetSection("Authentication")?.Get(); + return config.GetSection("Authentication")?.Get() ?? new(); + } + + public static void ValidateAuthConfig(this IConfiguration config) + { + var authConfig = config.GetAuthConfig(); + + if (authConfig.Method < AuthMethod.None || authConfig.Method > AuthMethod.PubKey) + throw new ConfigurationException("Invalid auth method"); + + foreach (var user in authConfig.Users) + { + if (user.Name == null) + throw new ConfigurationException("Invalid user name"); + + if (authConfig.Method == AuthMethod.PubKey) + { + try { _ = PubKey.FromBase58(user.PubKey); } + catch { throw new ConfigurationException("Invalid user pubkey"); } + } + else if (authConfig.Method == AuthMethod.Password) + { + if (user.Password == null) + throw new ConfigurationException("Invalid user password"); + } + + if (user.Rights != null) + { + foreach (var right in user.Rights) + { + if (right.Access < Access.None || right.Access > Access.Write) + throw new ConfigurationException("Invalid user access type"); + } + } + } } } } \ No newline at end of file diff --git a/Tzkt.Api/Services/Auth/AuthService.cs b/Tzkt.Api/Services/Auth/AuthService.cs index 8bda228e6..a97610b74 100644 --- a/Tzkt.Api/Services/Auth/AuthService.cs +++ b/Tzkt.Api/Services/Auth/AuthService.cs @@ -5,15 +5,15 @@ namespace Tzkt.Api.Services.Auth { public interface IAuthService { - public bool TryAuthenticate(AuthHeaders headers, out string error); - public bool TryAuthenticate(AuthHeaders headers, string json, out string error); + public bool TryAuthenticate(AuthHeaders headers, AccessRights requestedRights, out string error); + public bool TryAuthenticate(AuthHeaders headers, AccessRights requestedRights, string json, out string error); } public static class AuthServiceExt { public static void AddAuthService(this IServiceCollection services, IConfiguration config) { - switch (config.GetAuthConfig()?.Method) + switch (config.GetAuthConfig().Method) { case AuthMethod.Password: services.AddSingleton(); diff --git a/Tzkt.Api/Services/Auth/DefaultAuth.cs b/Tzkt.Api/Services/Auth/DefaultAuth.cs index 4190f5181..175076a1f 100644 --- a/Tzkt.Api/Services/Auth/DefaultAuth.cs +++ b/Tzkt.Api/Services/Auth/DefaultAuth.cs @@ -2,13 +2,13 @@ { public class DefaultAuth : IAuthService { - public bool TryAuthenticate(AuthHeaders headers, out string error) + public bool TryAuthenticate(AuthHeaders headers, AccessRights requestedRights, out string error) { error = null; return true; } - public bool TryAuthenticate(AuthHeaders headers, string json, out string error) + public bool TryAuthenticate(AuthHeaders headers, AccessRights requestedRights, string json, out string error) { error = null; return true; diff --git a/Tzkt.Api/Services/Auth/Models/AccessRights.cs b/Tzkt.Api/Services/Auth/Models/AccessRights.cs new file mode 100644 index 000000000..d3fe6b9fa --- /dev/null +++ b/Tzkt.Api/Services/Auth/Models/AccessRights.cs @@ -0,0 +1,17 @@ +namespace Tzkt.Api.Services.Auth +{ + + public class AccessRights + { + public string Table { get; set; } + public string Section { get; set; } + public Access Access {get;set;} + } + + public enum Access + { + None, + Read, + Write + } +} diff --git a/Tzkt.Api/Services/Auth/Models/AuthUser.cs b/Tzkt.Api/Services/Auth/Models/AuthUser.cs new file mode 100644 index 000000000..f6c291e3d --- /dev/null +++ b/Tzkt.Api/Services/Auth/Models/AuthUser.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace Tzkt.Api.Services.Auth +{ + public class AuthUser + { + public string Name { get; set; } + public string Password { get; set; } + public string PubKey { get; set; } + public List Rights { get; set; } + } +} \ No newline at end of file diff --git a/Tzkt.Api/Services/Auth/Models/MetadataUpdate.cs b/Tzkt.Api/Services/Auth/Models/MetadataUpdate.cs index f0adb4dda..fcf993947 100644 --- a/Tzkt.Api/Services/Auth/Models/MetadataUpdate.cs +++ b/Tzkt.Api/Services/Auth/Models/MetadataUpdate.cs @@ -10,9 +10,6 @@ public class MetadataUpdate : MetadataUpdate public class MetadataUpdate { - [JsonPropertyName("section")] - public string Section { get; set; } - [JsonPropertyName("metadata")] public RawJson Metadata { get; set; } } diff --git a/Tzkt.Api/Services/Auth/PasswordAuth.cs b/Tzkt.Api/Services/Auth/PasswordAuth.cs index 4d122420a..cc4bbde3e 100644 --- a/Tzkt.Api/Services/Auth/PasswordAuth.cs +++ b/Tzkt.Api/Services/Auth/PasswordAuth.cs @@ -1,17 +1,28 @@ +using System.Collections.Generic; +using System.Linq; using Microsoft.Extensions.Configuration; namespace Tzkt.Api.Services.Auth { public class PasswordAuth : IAuthService { - readonly AuthConfig Config; + readonly Dictionary sections)>> Rights; + readonly Dictionary Users; public PasswordAuth(IConfiguration config) { - Config = config.GetAuthConfig(); + var cfg = config.GetAuthConfig(); + Rights = cfg.Users.ToDictionary(x => x.Name, x => x.Rights? + .GroupBy(r => r.Table) + .ToDictionary(g => g.Key, g => + ( + g.FirstOrDefault(r => r.Section == null)?.Access ?? Access.None, + g.Where(r => r.Section != null).ToDictionary(r => r.Section, r => r.Access) + ))); + Users = cfg.Users.ToDictionary(x => x.Name); } - public bool TryAuthenticate(AuthHeaders headers, out string error) + public bool TryAuthenticate(AuthHeaders headers, AccessRights requestedRights, out string error) { error = null; @@ -27,24 +38,70 @@ public bool TryAuthenticate(AuthHeaders headers, out string error) return false; } - if (!Config.Credentials.TryGetValue(headers.User, out var password)) + if (!Users.TryGetValue(headers.User, out var user)) { error = $"User {headers.User} doesn't exist"; return false; } - - if (headers.Password != password) + + if (headers.Password != user.Password) { error = $"Invalid password"; return false; } + if (!Rights.TryGetValue(headers.User, out var rights)) + { + error = $"User {headers.User} doesn't exist"; + return false; + } + + if (rights == null) + { + return true; + } + + if (!rights.TryGetValue(requestedRights.Table, out var tableRights)) + { + error = $"User {headers.User} doesn't have required permissions. {requestedRights.Table} required."; + return false; + } + + if (tableRights.access >= requestedRights.Access) + { + return true; + } + + if (requestedRights.Section == null) + { + error = $"User {headers.User} doesn't have required permissions. {requestedRights.Access} required."; + return false; + } + + if (!tableRights.sections.TryGetValue(requestedRights.Section, out var sectionAccess)) + { + error = $"User {headers.User} doesn't have required permissions. {requestedRights.Section} required."; + return false; + } + + if (sectionAccess < requestedRights.Access) + { + error = $"User {headers.User} doesn't have required permissions. {requestedRights.Access} required. {sectionAccess} granted"; + return false; + } + return true; } - public bool TryAuthenticate(AuthHeaders headers, string json, out string error) + public bool TryAuthenticate(AuthHeaders headers, AccessRights requestedRights, string json, out string error) { - return TryAuthenticate(headers, out error); + if (string.IsNullOrEmpty(json)) + { + error = "Request body is empty"; + return false; + } + + return TryAuthenticate(headers, requestedRights, out error); } } } \ No newline at end of file diff --git a/Tzkt.Api/Services/Auth/PubKeyAuth.cs b/Tzkt.Api/Services/Auth/PubKeyAuth.cs index 236146e55..b40721135 100644 --- a/Tzkt.Api/Services/Auth/PubKeyAuth.cs +++ b/Tzkt.Api/Services/Auth/PubKeyAuth.cs @@ -12,101 +12,95 @@ public class PubKeyAuth : IAuthService { readonly AuthConfig Config; readonly Dictionary Nonces; - + readonly Dictionary sections)>> Rights; + readonly Dictionary PubKeys; + public PubKeyAuth(IConfiguration config) { - Config = config.GetAuthConfig(); - Nonces = Config.Credentials.ToDictionary(x => x.Key, x => long.MinValue ); + var cfg = config.GetAuthConfig(); + Config = cfg; + Nonces = cfg.Users.ToDictionary(x => x.Name, _ => long.MinValue ); + Rights = cfg.Users.ToDictionary(x => x.Name, x => x.Rights? + .GroupBy(g => g.Table) + .ToDictionary(g => g.Key, g => + ( + g.FirstOrDefault(r => r.Section == null)?.Access ?? Access.None, + g.Where(r => r.Section != null).ToDictionary(r => r.Section, r => r.Access) + ))); + PubKeys = cfg.Users.ToDictionary(x => x.Name, x => PubKey.FromBase58(x.PubKey)); } - public bool TryAuthenticate(AuthHeaders headers, out string error) + public bool TryAuthenticate(AuthHeaders headers, AccessRights requestedRights, out string error) { - error = null; - - if (string.IsNullOrEmpty(headers?.User)) + if (!TryAuthenticateBase(headers, requestedRights, out error, out var pubKey)) { - error = $"The X-TZKT-USER header is required"; return false; } - if (headers.Nonce == null) + if (!pubKey.Verify($"{headers.Nonce}", headers.Signature)) { - error = $"The X-TZKT-NONCE header is required"; - return false; - } - - if (string.IsNullOrEmpty(headers.Signature)) - { - error = $"The X-TZKT-SIGNATURE header is required"; - return false; - } - - if (!Config.Credentials.TryGetValue(headers.User, out var pubKey)) - { - error = $"User {headers.User} doesn't exist"; + error = $"Invalid signature"; return false; } - var nonce = (long)headers.Nonce; - var nonceTime = DateTime.UnixEpoch.AddMilliseconds(nonce); + Nonces[headers.User] = (long) headers.Nonce; + return true; + } - if (nonceTime < DateTime.UtcNow.AddSeconds(-Config.NonceLifetime)) + public bool TryAuthenticate(AuthHeaders headers, AccessRights requestedRights, string json, out string error) + { + if (!TryAuthenticateBase(headers, requestedRights, out error, out var pubKey)) { - error = $"Nonce too old. Server time: {DateTime.UtcNow}, nonce: {nonceTime}"; return false; } - - if (nonce <= Nonces[headers.User]) + + if (string.IsNullOrEmpty(json)) { - error = $"Nonce {nonce} has already used"; + error = $"Request body is empty"; return false; } - var key = PubKey.FromBase58(pubKey); - if (!key.Verify($"{headers.Nonce}", headers.Signature)) + var hash = Hex.Convert(Blake2b.GetDigest(Utf8.Parse(json))); + + if (!pubKey.Verify($"{headers.Nonce}{hash}", headers.Signature)) { error = $"Invalid signature"; return false; } - - Nonces[headers.User] = nonce; + + Nonces[headers.User] = (long)headers.Nonce; return true; } - public bool TryAuthenticate(AuthHeaders headers, string json, out string error) + private bool TryAuthenticateBase(AuthHeaders headers, AccessRights requestedRights, out string error, out PubKey pubKey) { error = null; - - if (string.IsNullOrEmpty(headers.User)) + pubKey = null; + + if (string.IsNullOrEmpty(headers?.User)) { - error = $"The X-TZKT-USER header is required"; + error = "The X-TZKT-USER header is required"; return false; } if (headers.Nonce == null) { - error = $"The X-TZKT-NONCE header is required"; + error = "The X-TZKT-NONCE header is required"; return false; } if (string.IsNullOrEmpty(headers.Signature)) { - error = $"The X-TZKT-SIGNATURE header is required"; - return false; - } - - if (string.IsNullOrEmpty(json)) - { - error = $"The body is empty"; + error = "The X-TZKT-SIGNATURE header is required"; return false; } - if (!Config.Credentials.TryGetValue(headers.User, out var pubKey)) + if (!PubKeys.TryGetValue(headers.User, out pubKey)) { error = $"User {headers.User} doesn't exist"; return false; } - + var nonce = (long)headers.Nonce; var nonceTime = DateTime.UnixEpoch.AddMilliseconds(nonce); @@ -118,20 +112,50 @@ public bool TryAuthenticate(AuthHeaders headers, string json, out string error) if (nonce <= Nonces[headers.User]) { - error = $"Nonce {nonce} has already used"; + error = $"Nonce {nonce} has already been used"; return false; } - var hash = Hex.Convert(Blake2b.GetDigest(Utf8.Parse(json))); + if (!Rights.TryGetValue(headers.User, out var rights)) + { + error = $"User {headers.User} doesn't exist"; + return false; + } + + if (rights == null) + { + return true; + } - var key = PubKey.FromBase58(pubKey); - if (!key.Verify($"{headers.Nonce}{hash}", headers.Signature)) + if (!rights.TryGetValue(requestedRights.Table, out var tableRights)) { - error = $"Invalid signature"; + error = $"User {headers.User} doesn't have required permissions. {requestedRights.Table} required."; + return false; + } + + if (tableRights.access >= requestedRights.Access) + { + return true; + } + + if (requestedRights.Section == null) + { + error = $"User {headers.User} doesn't have required permissions. {requestedRights.Access} required."; + return false; + } + + if (!tableRights.sections.TryGetValue(requestedRights.Section, out var sectionAccess)) + { + error = $"User {headers.User} doesn't have required permissions. {requestedRights.Section} required."; return false; } - Nonces[headers.User] = nonce; + if (sectionAccess < requestedRights.Access) + { + error = $"User {headers.User} doesn't have required permissions. {requestedRights.Access} required for section {requestedRights.Section}. {sectionAccess} granted"; + return false; + } + return true; } } diff --git a/Tzkt.Api/Utils/ConfigurationException.cs b/Tzkt.Api/Utils/ConfigurationException.cs new file mode 100644 index 000000000..8141f76f8 --- /dev/null +++ b/Tzkt.Api/Utils/ConfigurationException.cs @@ -0,0 +1,9 @@ +using System; + +namespace Tzkt.Api +{ + class ConfigurationException : Exception + { + public ConfigurationException(string message) : base($"Bad configuration: {message}") { } + } +} diff --git a/Tzkt.sln b/Tzkt.sln index dd81050ca..815319924 100644 --- a/Tzkt.sln +++ b/Tzkt.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tzkt.Data", "Tzkt.Data\Tzkt EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tzkt.Sync", "Tzkt.Sync\Tzkt.Sync.csproj", "{2A9CB3C6-92C4-4CEA-9091-821CB2A1F955}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tzkt.Api.Tests", "Tzkt.Api.Tests\Tzkt.Api.Tests.csproj", "{EF9A9BF1-4EA6-4D8A-8571-9860C2069439}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +29,10 @@ Global {2A9CB3C6-92C4-4CEA-9091-821CB2A1F955}.Debug|Any CPU.Build.0 = Debug|Any CPU {2A9CB3C6-92C4-4CEA-9091-821CB2A1F955}.Release|Any CPU.ActiveCfg = Release|Any CPU {2A9CB3C6-92C4-4CEA-9091-821CB2A1F955}.Release|Any CPU.Build.0 = Release|Any CPU + {EF9A9BF1-4EA6-4D8A-8571-9860C2069439}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EF9A9BF1-4EA6-4D8A-8571-9860C2069439}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EF9A9BF1-4EA6-4D8A-8571-9860C2069439}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EF9A9BF1-4EA6-4D8A-8571-9860C2069439}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE