diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index cbcc125..973bc41 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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" \ No newline at end of file diff --git a/src/dotnet/.idea/.idea.Suspectus.Gandalf/.idea/dataSources.xml b/src/dotnet/.idea/.idea.Suspectus.Gandalf/.idea/dataSources.xml index 2d89c97..015da6c 100644 --- a/src/dotnet/.idea/.idea.Suspectus.Gandalf/.idea/dataSources.xml +++ b/src/dotnet/.idea/.idea.Suspectus.Gandalf/.idea/dataSources.xml @@ -9,5 +9,13 @@ jdbc:postgresql://localhost:5432/gandalf_reborn?logServerErrorDetail=True&password=root&user=root $ProjectFileDir$ + + redis + true + true + jdbc.RedisDriver + jdbc:redis://localhost:6379/0 + $ProjectFileDir$ + \ No newline at end of file diff --git a/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Controllers/AppProxyController.cs b/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Controllers/AppProxyController.cs new file mode 100644 index 0000000..d315d66 --- /dev/null +++ b/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Controllers/AppProxyController.cs @@ -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 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(_ => 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 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}"; + } +} \ No newline at end of file diff --git a/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Controllers/AuthController.cs b/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Controllers/AuthController.cs index e0c8dbf..ac40b6d 100644 --- a/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Controllers/AuthController.cs +++ b/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Controllers/AuthController.cs @@ -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 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(valid => { diff --git a/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Program.cs b/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Program.cs index 25f01b4..d7da371 100644 --- a/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Program.cs +++ b/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Program.cs @@ -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(); diff --git a/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Suspectus.Gandalf.Bridgekeeper.Api.csproj b/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Suspectus.Gandalf.Bridgekeeper.Api.csproj index cff35d2..78fe9a0 100644 --- a/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Suspectus.Gandalf.Bridgekeeper.Api.csproj +++ b/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/Suspectus.Gandalf.Bridgekeeper.Api.csproj @@ -9,6 +9,7 @@ + diff --git a/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/appsettings.Development.json b/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/appsettings.Development.json index 0c208ae..852c1b1 100644 --- a/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/appsettings.Development.json +++ b/src/dotnet/Suspectus.Gandalf.Bridgekeeper.Api/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "ConnectionStrings": { + "Redis": "localhost:6379" } } diff --git a/src/dotnet/Suspectus.Gandalf.Core.Abstractions/DTOs/AppInfo.cs b/src/dotnet/Suspectus.Gandalf.Core.Abstractions/DTOs/AppInfo.cs new file mode 100644 index 0000000..e0195c6 --- /dev/null +++ b/src/dotnet/Suspectus.Gandalf.Core.Abstractions/DTOs/AppInfo.cs @@ -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; } +} \ No newline at end of file diff --git a/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/AppController.cs b/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/AppController.cs new file mode 100644 index 0000000..385a7a8 --- /dev/null +++ b/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/AppController.cs @@ -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 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 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); + } +} \ No newline at end of file diff --git a/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/AuthController.cs b/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/AuthController.cs index b946def..d4beec0 100644 --- a/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/AuthController.cs +++ b/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/AuthController.cs @@ -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]")] diff --git a/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/TenantController.cs b/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/TenantController.cs new file mode 100644 index 0000000..fa3cbfb --- /dev/null +++ b/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/TenantController.cs @@ -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 Get(CancellationToken cancellationToken) + { + var tenantEntities = await _context.Tenants.ToListAsync(cancellationToken: cancellationToken); + return Ok(tenantEntities); + } +} \ No newline at end of file diff --git a/src/dotnet/Suspectus.Gandalf.Palantir.Client/Extensions/ServiceCollectionExtensions.cs b/src/dotnet/Suspectus.Gandalf.Palantir.Client/Extensions/ServiceCollectionExtensions.cs index 4d477e0..8b32f4d 100644 --- a/src/dotnet/Suspectus.Gandalf.Palantir.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/dotnet/Suspectus.Gandalf.Palantir.Client/Extensions/ServiceCollectionExtensions.cs @@ -6,15 +6,21 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddPalantirClient(this IServiceCollection services) { - services.AddHttpClient(opt => - { - opt.BaseAddress = new Uri("https://localhost:7269/api/auth/"); - }) - .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler //TODO: Remove this in production - { - ServerCertificateCustomValidationCallback = - HttpClientHandler.DangerousAcceptAnyServerCertificateValidator - }); + services.AddHttpClient(opt => { opt.BaseAddress = new Uri("https://localhost:7269/api/internal/auth/"); }) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler //TODO: Remove this in production + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }); + + services.AddHttpClient(opt => { opt.BaseAddress = new Uri("https://localhost:7269/api/internal/app/"); }) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler //TODO: Remove this in production + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }); + + services.AddScoped(); services.AddScoped(); return services; } diff --git a/src/dotnet/Suspectus.Gandalf.Palantir.Client/PalantirClient.cs b/src/dotnet/Suspectus.Gandalf.Palantir.Client/PalantirClient.cs index 6bf8f07..6e16e67 100644 --- a/src/dotnet/Suspectus.Gandalf.Palantir.Client/PalantirClient.cs +++ b/src/dotnet/Suspectus.Gandalf.Palantir.Client/PalantirClient.cs @@ -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> ValidateCredentials(ValidateCredentialsCommand validateCredentialsCommand); } -public class PalantirClient : IPalantirClient +public interface IPalantirInternalAppClient { - public PalantirClient(IPalantirAuthClient authClient) - { - Auth = authClient; - } - - public IPalantirAuthClient Auth { get; init; } + public Task> 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(); } - + var result = await response.Content.ReadFromJsonAsync(); return result; } @@ -53,4 +83,40 @@ public class PalantirAuthClient : IPalantirAuthClient return $"status: {e.StatusCode} message: {e.Message}".AsErrorResult(); } } +} + +public class PalantirInternalAppClient : IPalantirInternalAppClient +{ + private readonly HttpClient _http; + + public PalantirInternalAppClient(HttpClient http) + { + _http = http; + } + + public async Task> GetInfo(string appId) + { + try + { + var response = await _http.GetAsync($"info/{appId}"); + + if (!response.IsSuccessStatusCode) + { + return $"status: {response.StatusCode}".AsErrorResult(); + } + + var result = await response.Content.ReadFromJsonAsync(); + + if (result is null) + { + return "InternalApp not found.".AsErrorResult(); + } + + return result; + } + catch (HttpRequestException e) + { + return $"status: {e.StatusCode} message: {e.Message}".AsErrorResult(); + } + } } \ No newline at end of file