Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added interceptor to authenticate through api token #305

Merged
merged 19 commits into from
Oct 16, 2023
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
43 changes: 43 additions & 0 deletions src/Data/DbInitializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,33 @@ public static void Initialize(IServiceProvider serviceProvider)
applicationDbContext.Add(testingSinglesigWallet);
applicationDbContext.Add(testingSingleSigBIP39Wallet);
}

// API Tokens generation for services
var authenticatedServices = new Dictionary<string, string>
{
{ "BTCPay", "9Hoz0PMYCsnPUzPO/JbJu8UdaKaAHJsh946xH20UzA0=" },
{ "X", "C+ktTkMGQupY9LY3IkpyqQQ2pDa7idaeSUKUnm+RawI=" },
{ "Liquidator", "8rvSsUGeyXXdDQrHctcTey/xtHdZQEn945KHwccKp9Q=" }
};

var existingTokens = applicationDbContext.ApiTokens.Where(token => authenticatedServices.Keys.Contains(token.Name)).ToList();


if (existingTokens.Count != authenticatedServices.Count && adminUser != null)
{
foreach (var service in authenticatedServices)
{
// Check if the service exists in existingTokens
if (!existingTokens.Any(token => token.Name == service.Key))
{
// The service does not exist in existingTokens, so create a new ApiToken
var newToken = CreateApiToken(service.Key, service.Value, adminUser.Id);

// Add the new token to the database
applicationDbContext.ApiTokens.Add(newToken);
}
}
}
}

applicationDbContext.SaveChanges();
Expand Down Expand Up @@ -426,6 +453,22 @@ private static void SetRoles(RoleManager<IdentityRole>? roleManager)
}
}

private static APIToken CreateApiToken(string name, string token, string userId)
{
var apiToken = new APIToken
{
Name = name,
TokenHash = token,
IsBlocked = false,
CreatorId = userId
};

apiToken.SetCreationDatetime();
apiToken.SetUpdateDatetime();

return apiToken;
}

private static NewTransactionEvent WaitNbxplorerNotification(LongPollingNotificationSession evts, DerivationStrategyBase derivationStrategy)
{
while (true)
Expand Down
6 changes: 2 additions & 4 deletions src/Data/Models/APIToken.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Security.Cryptography;
using Blazorise;

using Microsoft.AspNetCore.Cryptography.KeyDerivation;

namespace NodeGuard.Data.Models;
Expand All @@ -18,7 +17,7 @@ public class APIToken: Entity
public DateTime? ExpirationDate { get; set; }

#endregion Relationships

public void GenerateTokenHash(string password, string salt)
{

Expand All @@ -30,7 +29,6 @@ public void GenerateTokenHash(string password, string salt)
numBytesRequested: 256 / 8));

TokenHash = hashed;

}


Expand Down
24 changes: 21 additions & 3 deletions src/Data/Repositories/APITokenRepository.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Security.Cryptography;
using Google.Protobuf.WellKnownTypes;
using Microsoft.EntityFrameworkCore;
using NodeGuard.Data.Models;
using NodeGuard.Data.Repositories.Interfaces;
Expand All @@ -21,6 +22,7 @@ public APITokenRepository(IRepository<APIToken> repository,
_dbContextFactory = dbContextFactory;
}


public async Task<(bool, string?)> AddAsync(APIToken type)
{
await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync();
Expand Down Expand Up @@ -55,6 +57,12 @@ public APITokenRepository(IRepository<APIToken> repository,
}
}

public async Task<APIToken?> GetByToken(string token)
{
await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync();
var result = await applicationDbContext.ApiTokens.FirstOrDefaultAsync(x => x.TokenHash == token);
return result;
}
public async Task<List<APIToken>> GetAll()
{
await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync();
Expand All @@ -73,17 +81,27 @@ public async Task<List<APIToken>> GetAll()
}

public bool BlockToken(APIToken type)
{
return ChangeBlockStatus(type, true);
}

public bool UnblockToken(APIToken type)
{
return ChangeBlockStatus(type, false);
}

private bool ChangeBlockStatus(APIToken type, bool status)
{
try
{
type.IsBlocked = true;
type.IsBlocked = status;
Update(type);
return true;
}
catch (Exception e)
{
const string errorWhileBlockingToken = "Error while blocking token";
_logger.LogError(e, errorWhileBlockingToken);
var errorWhileChangingBlockStatus = status ? "Error while blocking token" : "Error while unblocking token";
_logger.LogError(e, errorWhileChangingBlockStatus);
return false;
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/Data/Repositories/Interfaces/IAPITokenRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ namespace NodeGuard.Data.Repositories.Interfaces;
public interface IAPITokenRepository
{
Task<(bool, string?)> AddAsync(APIToken type);
Task<APIToken?> GetByToken(string token);
Task<List<APIToken>> GetAll();
(bool, string?) Update(APIToken type);
bool BlockToken(APIToken type);
bool UnblockToken(APIToken type);

}
2 changes: 2 additions & 0 deletions src/Helpers/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
public static readonly string? AWS_ACCESS_KEY_ID;
public static readonly string? AWS_SECRET_ACCESS_KEY;
public static readonly string API_TOKEN_SALT;
public static readonly bool GRPC_AUTH_FEATURE_FLAG;

// Crons & Jobs
public static readonly string MONITOR_WITHDRAWALS_CRON = "10 0/5 * * * ?";
Expand Down Expand Up @@ -116,7 +117,7 @@
// Connections
POSTGRES_CONNECTIONSTRING = Environment.GetEnvironmentVariable("POSTGRES_CONNECTIONSTRING") ?? POSTGRES_CONNECTIONSTRING;

NBXPLORER_URI = GetEnvironmentalVariableOrThrowIfNotTesting("NBXPLORER_URI");

Check warning on line 120 in src/Helpers/Constants.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference assignment.

NBXPLORER_BTCRPCURL = Environment.GetEnvironmentVariable("NBXPLORER_BTCRPCURL");

Expand All @@ -143,7 +144,7 @@

if (PUSH_NOTIFICATIONS_ONESIGNAL_ENABLED)
{
PUSH_NOTIFICATIONS_ONESIGNAL_APP_ID = GetEnvironmentalVariableOrThrowIfNotTesting("PUSH_NOTIFICATIONS_ONESIGNAL_APP_ID", "if PUSH_NOTIFICATIONS_ONESIGNAL_ENABLED is set, PUSH_NOTIFICATIONS_ONESIGNAL_APP_ID");

Check warning on line 147 in src/Helpers/Constants.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference assignment.

PUSH_NOTIFICATIONS_ONESIGNAL_API_BASE_PATH = GetEnvironmentalVariableOrThrowIfNotTesting("PUSH_NOTIFICATIONS_ONESIGNAL_API_BASE_PATH", "if PUSH_NOTIFICATIONS_ONESIGNAL_ENABLED is set,PUSH_NOTIFICATIONS_ONESIGNAL_API_BASE_PATH");

Expand All @@ -165,6 +166,7 @@

API_TOKEN_SALT = Environment.GetEnvironmentVariable("API_TOKEN_SALT") ?? "H/fCx1+maAFMcdi6idIYEg==";

GRPC_AUTH_FEATURE_FLAG = Environment.GetEnvironmentVariable("GRPC_AUTH_FEATURE_FLAG") == "true";

// Crons & Jobs
MONITOR_WITHDRAWALS_CRON = Environment.GetEnvironmentVariable("MONITOR_WITHDRAWALS_CRON") ?? MONITOR_WITHDRAWALS_CRON;
Expand Down Expand Up @@ -193,7 +195,7 @@
}

// Usage
BITCOIN_NETWORK = Environment.GetEnvironmentVariable("BITCOIN_NETWORK");

Check warning on line 198 in src/Helpers/Constants.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference assignment.

var minChannelCapacity = GetEnvironmentalVariableOrThrowIfNotTesting("MINIMUM_CHANNEL_CAPACITY_SATS");
if (minChannelCapacity != null) MINIMUM_CHANNEL_CAPACITY_SATS = long.Parse(minChannelCapacity, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture);
Expand Down
21 changes: 17 additions & 4 deletions src/Pages/Apis.razor
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@
<Button Color="Color.Primary" Clicked="@context.Clicked">Edit</Button>
@if (!context.Item.IsBlocked)
{
<Button Color="Color.Danger" Clicked="@(()=>OnBlockTokenClicked(context.Item))">Block</Button>
<Button Color="Color.Danger" Clicked="@(()=>OnBlockStatusTokenClicked(context.Item))">Block</Button>
}
else
{
<Button Color="Color.Success" Clicked="@(()=>OnBlockStatusTokenClicked(context.Item, false))">Unblock</Button>
}
</Buttons>
</EditCommandTemplate>
Expand All @@ -48,6 +52,15 @@
</DeleteCommandTemplate>
</DataGridCommandColumn>
<DataGridColumn TItem="APIToken" Editable="true" Field="@nameof(APIToken.Name)" Caption="Name" Sortable="false" Displayable="@IsColumnVisible(APITokenColumnName.Name)" Filterable="true">
<EditTemplate>
<Validation Validator="@ValidationHelper.ValidateName">
<TextEdit Text="@((string) context.CellValue)" TextChanged="(text) => { context.CellValue = text; }">
<Feedback>
<ValidationError/>
</Feedback>
</TextEdit>
</Validation>
</EditTemplate>
</DataGridColumn>
<DataGridColumn TItem="APIToken" Field="@nameof(APIToken.CreationDatetime)" Caption="Creation time" Sortable="true" SortDirection="SortDirection.Descending" Displayable="@IsColumnVisible(APITokenColumnName.CreationDatetime)" Filterable="false">
<DisplayTemplate>
Expand Down Expand Up @@ -193,15 +206,15 @@
return APITokenColumnLayout.IsColumnVisible(column);
}

private async Task OnBlockTokenClicked(APIToken contextItem)
private async Task OnBlockStatusTokenClicked(APIToken contextItem, bool blockIt = true)
{
if (contextItem != null)
{
var result= APITokenRepository.BlockToken(contextItem);
var result= blockIt ? APITokenRepository.BlockToken(contextItem) : APITokenRepository.UnblockToken(contextItem);

if (result)
{
ToastService.ShowSuccess("Token blocked");
ToastService.ShowSuccess(blockIt ? "Token blocked" : "Token unblocked");
}
else
{
Expand Down
1 change: 1 addition & 0 deletions src/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ public static void Main(string[] args)
var messageSize = 200 * 1024 * 1024; // 200 MB
options.MaxReceiveMessageSize = messageSize;
options.MaxSendMessageSize = messageSize;
options.Interceptors.Add<GRPCAuthInterceptor>();
});
builder.WebHost.ConfigureKestrel(options =>
{
Expand Down
3 changes: 2 additions & 1 deletion src/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"COINGECKO_ENDPOINT": "https://pro-api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=bitcoin",
"COINGECKO_KEY": "TBD",
"HTTP1_LISTEN_PORT": "38080",
"API_TOKEN_SALT": "H/fCx1+maAFMcdi6idIYEg=="
"API_TOKEN_SALT": "H/fCx1+maAFMcdi6idIYEg==",
"GRPC_AUTH_FEATURE_FLAG": "false"
}
}
}
Expand Down
40 changes: 40 additions & 0 deletions src/Rpc/GRPCAuthInterceptor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Grpc.Core;
using Grpc.Core.Interceptors;
using NodeGuard.Data.Repositories.Interfaces;

namespace NodeGuard.Rpc;

public class GRPCAuthInterceptor : Interceptor
{
private readonly IAPITokenRepository _apiTokenRepository;

public GRPCAuthInterceptor(IAPITokenRepository apiTokenRepository)
{
_apiTokenRepository = apiTokenRepository;
}

public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{

if (Constants.GRPC_AUTH_FEATURE_FLAG) {
var token = context.RequestHeaders.FirstOrDefault(x => x.Key == "auth-token")?.Value;
if (token == null)
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "No token provided"));
}

var apiToken = await _apiTokenRepository.GetByToken(token);

if (apiToken?.IsBlocked ?? true)
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid token"));
}
}

return await continuation(request, context);
}

}
108 changes: 108 additions & 0 deletions test/NodeGuard.Tests/Rpc/GRPCAuthInterceptorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@

using FluentAssertions;
using Grpc.Core;
using NodeGuard.Data.Models;
using NodeGuard.Data.Repositories.Interfaces;

namespace NodeGuard.Rpc;

public class AuthInterceptorTests
{
[Fact]
public async Task AuthInterceptor_NoTokenProvided()
{
// Arrange
var context = TestServerCallContext.Create();
var mockedApiTokenRepository = new Mock<IAPITokenRepository>();
var interceptor = new GRPCAuthInterceptor(mockedApiTokenRepository.Object);
var continuation = new UnaryServerMethod<string, string>(async (request, context) => { return "response"; });

// Act & Assert
var act = () => interceptor.UnaryServerHandler<string, string>(String.Empty, context, continuation);

// Assert
await act
.Should()
.ThrowAsync<RpcException>()
.WithMessage($"Status(StatusCode=\"Unauthenticated\", Detail=\"No token provided\")");
}

[Fact]
public async Task AuthInterceptor_NonExistingToken()
{
// Arrange
var context = TestServerCallContext.Create(new Metadata{{"auth-token", "iamastupidtoken"}});
var mockedApiTokenRepository = new Mock<IAPITokenRepository>();
var interceptor = new GRPCAuthInterceptor(mockedApiTokenRepository.Object);
var continuation = new UnaryServerMethod<string, string>(async (request, context) => { return "response"; });

// Act
var act = () => interceptor.UnaryServerHandler<string, string>(String.Empty, context, continuation);

// Assert
await act
.Should()
.ThrowAsync<RpcException>()
.WithMessage("Status(StatusCode=\"Unauthenticated\", Detail=\"Invalid token\")");

}

[Fact]
public async Task AuthInterceptor_ExistingTokenValid()
{
// Arrange
var validToken = "iamavalidtoken";
var apiTokenFixture = new APIToken { TokenHash = validToken };
var mockedApiTokenRepository = new Mock<IAPITokenRepository>();
//GetBytoken mocked to return a valid token
mockedApiTokenRepository.Setup(x => x.GetByToken(It.IsAny<string>()))
.ReturnsAsync(apiTokenFixture);

var context = TestServerCallContext.Create(new Metadata{{"auth-token", validToken}});
var interceptor = new GRPCAuthInterceptor(mockedApiTokenRepository.Object);
var mockContinuation = new Mock<UnaryServerMethod<string, string>>();
mockContinuation.Setup(x => x.Invoke(
It.IsAny<string>(),
It.IsAny<TestServerCallContext>())
)
.ReturnsAsync("response");

// In order to test if the request is continued, which would mean that the token is valid,
// we mock the continuation function and we verify that it is called once.

// Act
await interceptor.UnaryServerHandler<string, string>(String.Empty, context, mockContinuation.Object);

// Assert
mockContinuation.Verify(
c => c(
It.IsAny<string>(),
It.IsAny<TestServerCallContext>()),
Times.Once);
}

[Fact]
public async Task AuthInterceptor_ExistingTokenInvalid()
{
// Arrange
var validToken = "iamaninvalidtoken";
var apiTokenFixture = new APIToken { TokenHash = validToken , IsBlocked = true};
var mockedApiTokenRepository = new Mock<IAPITokenRepository>();
//GetBytoken mocked to return a valid token
mockedApiTokenRepository.Setup(x => x.GetByToken(It.IsAny<string>()))
.ReturnsAsync(apiTokenFixture);

var context = TestServerCallContext.Create(new Metadata{{"auth-token", validToken}});
var interceptor = new GRPCAuthInterceptor(mockedApiTokenRepository.Object);
var continuation = new UnaryServerMethod<string, string>(async (request, context) => { return "response"; });

// Act
var act = () => interceptor.UnaryServerHandler(String.Empty, context, continuation);

// Assert
await act
.Should()
.ThrowAsync<RpcException>()
.WithMessage("Status(StatusCode=\"Unauthenticated\", Detail=\"Invalid token\")");
}
}
Loading