Add Redis caching and AppProxyController for internal API communication

This commit is contained in:
Christian Werner 2025-05-30 01:26:49 +02:00
parent 3e830d2488
commit e6b0e4ab99
13 changed files with 335 additions and 27 deletions

View File

@ -10,4 +10,10 @@ services:
POSTGRES_DB: gandalf_reborn
ports:
- "5432:5432"
token-cache:
image: redis:alpine
container_name: token-cache
restart: always
ports:
- "6379:6379"

View File

@ -9,5 +9,13 @@
<jdbc-url>jdbc:postgresql://localhost:5432/gandalf_reborn?logServerErrorDetail=True&amp;password=root&amp;user=root</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="0@localhost" uuid="83278897-40f2-4e21-8bf8-d664f37bfde1">
<driver-ref>redis</driver-ref>
<synchronize>true</synchronize>
<configured-by-url>true</configured-by-url>
<jdbc-driver>jdbc.RedisDriver</jdbc-driver>
<jdbc-url>jdbc:redis://localhost:6379/0</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@ -0,0 +1,109 @@
using System.Net;
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Server.HttpSys;
using Microsoft.Extensions.Caching.Distributed;
using Suspectus.Gandalf.Core.Abstractions.Extensions;
using Suspectus.Gandalf.Palantir.Client;
namespace Suspectus.Gandalf.Bridgekeeper.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AppProxyController : ControllerBase
{
private readonly IDistributedCache _tokenCache;
private readonly IPalantirClient _client;
private readonly HttpClient _httpClient;
public AppProxyController(IDistributedCache tokenCache, IPalantirClient client, IHttpClientFactory httpClientFactory)
{
_tokenCache = tokenCache;
_client = client;
_httpClient = httpClientFactory.CreateClient("test");
}
[HttpGet("{appId}/{*path}")]
public async Task<IActionResult> Get(string appId, string path, CancellationToken cancellationToken)
{
var sessionCookie = Request.Cookies["MithrandirSession"];
if (sessionCookie is null)
{
return Unauthorized("Session expired.");
}
var appInfoResult = await _client.Internal.App.GetInfo(appId);
if (!appInfoResult.IsSuccess)
{
return BadRequest(appInfoResult.Match<string?>(_ => null, e => e.Message));
}
var appInfo = appInfoResult.GetValue();
if (appInfo.IsMaster && path.StartsWith("api/internal"))
{
return NotFound();
}
var token = await GetToken(sessionCookie, appId, cancellationToken);
_httpClient.BaseAddress = new Uri(appInfo.BaseAddress);
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
try
{
var clientResponse = await _httpClient.GetAsync(path, cancellationToken);
if (!clientResponse.IsSuccessStatusCode)
{
Response.StatusCode = (int)clientResponse.StatusCode;
}
foreach (var header in clientResponse.Content.Headers)
{
Response.Headers[header.Key] = header.Value.ToArray();
}
await Response.WriteAsync(await clientResponse.Content.ReadAsStringAsync(cancellationToken), cancellationToken: cancellationToken);
}
catch (HttpRequestException e)
{
Response.StatusCode = e.StatusCode is null ? (int)HttpStatusCode.InternalServerError : (int)e.StatusCode;
await Response.WriteAsync(e.Message, cancellationToken: cancellationToken);
}
return new EmptyResult();
}
private async Task<string> GetToken(string subjectId, string appId, CancellationToken cancellationToken)
{
// TODO: Get actual token for subject and app
var accessTokenCacheKey = GetCacheKey(subjectId, appId, "access_token");
var accessToken = await _tokenCache.GetStringAsync(accessTokenCacheKey, cancellationToken);
if (accessToken is not null)
return accessToken;
const string newToken = "totallyARealToken"; // Replace with actual token retrieval logic
await _tokenCache.SetStringAsync(
accessTokenCacheKey,
newToken,
new DistributedCacheEntryOptions
{
AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(1),
},
cancellationToken
);
accessToken = newToken;
return accessToken;
}
private string GetCacheKey(string subjectId, string appId, string tokenType)
{
return $"{subjectId}:{appId}:{tokenType}";
}
}

View File

@ -10,12 +10,10 @@ namespace Suspectus.Gandalf.Bridgekeeper.Api.Controllers;
public class AuthController : ControllerBase
{
private readonly IPalantirClient _client;
private readonly IPalantirAuthClient _authClient;
public AuthController(IPalantirClient client, IPalantirAuthClient authClient)
public AuthController(IPalantirClient client)
{
_client = client;
_authClient = authClient;
}
[HttpGet("[action]")]
@ -27,7 +25,7 @@ public class AuthController : ControllerBase
[HttpPost("[action]")]
public async Task<IActionResult> Login([FromBody] LoginCommand loginCommand)
{
var validationResult = await _client.Auth.ValidateCredentials(new ValidateCredentialsCommand { Password = loginCommand.Password, UsernameOrEmail = loginCommand.UsernameOrEmail });
var validationResult = await _client.Internal.Auth.ValidateCredentials(new ValidateCredentialsCommand { Password = loginCommand.Password, UsernameOrEmail = loginCommand.UsernameOrEmail });
return validationResult.Match<IActionResult>(valid =>
{

View File

@ -1,8 +1,23 @@
using Suspectus.Gandalf.Bridgekeeper.Api.Controllers;
using Suspectus.Gandalf.Palantir.Client.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPalantirClient();
builder.Services.AddHttpClient("test")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler //TODO: Remove this in production
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
builder.Services.AddStackExchangeRedisCache(opt =>
{
var connectionString = builder.Configuration.GetConnectionString("Redis");
opt.Configuration = connectionString;
});
builder.Services.AddControllers();
builder.Services.AddOpenApi();

View File

@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2"/>
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.5" />
</ItemGroup>
<ItemGroup>

View File

@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"Redis": "localhost:6379"
}
}

View File

@ -0,0 +1,9 @@
namespace Suspectus.Gandalf.Core.Abstractions.DTOs;
public class AppInfo
{
public required string Id { get; set; }
public required bool IsMaster { get; set; }
public required string Name { get; set; }
public required string BaseAddress { get; set; }
}

View File

@ -0,0 +1,56 @@
using System.ComponentModel.DataAnnotations;
using HashidsNet;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Suspectus.Gandalf.Core.Abstractions.DTOs;
using Suspectus.Gandalf.Palantir.Data.Database;
namespace Suspectus.Gandalf.Palantir.Api.Controllers;
[ApiController]
[Route("api/internal/[controller]")]
public class AppController : ControllerBase
{
private readonly ApplicationContext _context;
private readonly IHashids _hashids;
public AppController(ApplicationContext context, IHashids hashids)
{
_context = context;
_hashids = hashids;
}
[HttpGet("info/{appId}")]
[AllowAnonymous]
public async Task<IActionResult> GetInfo([FromRoute][Required] string appId, CancellationToken cancellationToken)
{
var decodedId = _hashids.DecodeSingleLong(appId);
var appInfo = await _context.Apps
.Where(x => x.Id == decodedId)
.Select(x => new AppInfo
{
Id = appId,
IsMaster = x.Tenant!.IsMaster,
Name = x.Name,
BaseAddress = x.Tenant!.IsMaster ? "http://localhost:5035/" : "null"
})
.SingleOrDefaultAsync(cancellationToken: cancellationToken);
return Ok(appInfo);
}
[HttpGet("info")]
[AllowAnonymous]
public async Task<IActionResult> GetInfos(CancellationToken cancellationToken)
{
var appInfos = await _context.Apps.Select(x => new AppInfo
{
Id = _hashids.EncodeLong(x.Id!.Value),
IsMaster = x.Tenant!.IsMaster,
Name = x.Name,
BaseAddress = x.Tenant!.IsMaster ? "http://localhost:5035/" : "null"
}).ToListAsync(cancellationToken: cancellationToken);
return Ok(appInfos);
}
}

View File

@ -6,7 +6,7 @@ using Suspectus.Gandalf.Palantir.Contracts.Controller.Auth;
namespace Suspectus.Gandalf.Palantir.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
[Route("api/internal/[controller]")]
public class AuthController(IMediator mediator) : ControllerBase
{
[HttpPost("[action]")]

View File

@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using HashidsNet;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Suspectus.Gandalf.Core.Abstractions.DTOs;
using Suspectus.Gandalf.Palantir.Data.Database;
namespace Suspectus.Gandalf.Palantir.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class TenantController : ControllerBase
{
private readonly ApplicationContext _context;
private readonly IHashids _hashids;
public TenantController(ApplicationContext context, IHashids hashids)
{
_context = context;
_hashids = hashids;
}
[HttpGet]
public async Task<IActionResult> Get(CancellationToken cancellationToken)
{
var tenantEntities = await _context.Tenants.ToListAsync(cancellationToken: cancellationToken);
return Ok(tenantEntities);
}
}

View File

@ -6,15 +6,21 @@ public static class ServiceCollectionExtensions
{
public static IServiceCollection AddPalantirClient(this IServiceCollection services)
{
services.AddHttpClient<IPalantirAuthClient, PalantirAuthClient>(opt =>
{
opt.BaseAddress = new Uri("https://localhost:7269/api/auth/");
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler //TODO: Remove this in production
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
services.AddHttpClient<IPalantirInternalAuthClient, PalantirInternalAuthClient>(opt => { opt.BaseAddress = new Uri("https://localhost:7269/api/internal/auth/"); })
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler //TODO: Remove this in production
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
services.AddHttpClient<IPalantirInternalAppClient, PalantirInternalAppClient>(opt => { opt.BaseAddress = new Uri("https://localhost:7269/api/internal/app/"); })
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler //TODO: Remove this in production
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
services.AddScoped<IPalantirInternalClient, PalantirInternalClient>();
services.AddScoped<IPalantirClient, PalantirClient>();
return services;
}

View File

@ -1,5 +1,6 @@
using System.Net.Http.Json;
using LanguageExt.Common;
using Suspectus.Gandalf.Core.Abstractions.DTOs;
using Suspectus.Gandalf.Core.Abstractions.Extensions;
using Suspectus.Gandalf.Palantir.Contracts.Controller.Auth;
@ -7,29 +8,58 @@ namespace Suspectus.Gandalf.Palantir.Client;
public interface IPalantirClient
{
IPalantirAuthClient Auth { get; init; }
IPalantirInternalClient Internal { get; init; }
}
public interface IPalantirAuthClient
public interface IPalantirInternalClient
{
IPalantirInternalAuthClient Auth { get; init; }
IPalantirInternalAppClient App { get; init; }
}
public class PalantirInternalClient : IPalantirInternalClient
{
public PalantirInternalClient(
IPalantirInternalAuthClient auth,
IPalantirInternalAppClient app
)
{
Auth = auth;
App = app;
}
public IPalantirInternalAuthClient Auth { get; init; }
public IPalantirInternalAppClient App { get; init; }
}
public interface IPalantirInternalAuthClient
{
public Task<Result<bool>> ValidateCredentials(ValidateCredentialsCommand validateCredentialsCommand);
}
public class PalantirClient : IPalantirClient
public interface IPalantirInternalAppClient
{
public PalantirClient(IPalantirAuthClient authClient)
{
Auth = authClient;
}
public IPalantirAuthClient Auth { get; init; }
public Task<Result<AppInfo>> GetInfo(string appId);
}
public class PalantirAuthClient : IPalantirAuthClient
public class PalantirClient : IPalantirClient
{
public PalantirClient(
IPalantirInternalClient internalClient
)
{
Internal = internalClient;
}
public IPalantirInternalClient Internal { get; init; }
}
public class PalantirInternalAuthClient : IPalantirInternalAuthClient
{
private readonly HttpClient _http;
public PalantirAuthClient(HttpClient http)
public PalantirInternalAuthClient(HttpClient http)
{
_http = http;
}
@ -39,12 +69,12 @@ public class PalantirAuthClient : IPalantirAuthClient
try
{
var response = await _http.PostAsJsonAsync("validate-credentials", validateCredentialsCommand);
if (!response.IsSuccessStatusCode)
{
return $"status: {response.StatusCode}".AsErrorResult<bool>();
}
var result = await response.Content.ReadFromJsonAsync<bool>();
return result;
}
@ -53,4 +83,40 @@ public class PalantirAuthClient : IPalantirAuthClient
return $"status: {e.StatusCode} message: {e.Message}".AsErrorResult<bool>();
}
}
}
public class PalantirInternalAppClient : IPalantirInternalAppClient
{
private readonly HttpClient _http;
public PalantirInternalAppClient(HttpClient http)
{
_http = http;
}
public async Task<Result<AppInfo>> GetInfo(string appId)
{
try
{
var response = await _http.GetAsync($"info/{appId}");
if (!response.IsSuccessStatusCode)
{
return $"status: {response.StatusCode}".AsErrorResult<AppInfo>();
}
var result = await response.Content.ReadFromJsonAsync<AppInfo>();
if (result is null)
{
return "InternalApp not found.".AsErrorResult<AppInfo>();
}
return result;
}
catch (HttpRequestException e)
{
return $"status: {e.StatusCode} message: {e.Message}".AsErrorResult<AppInfo>();
}
}
}