diff --git a/FictionArchive.API/FictionArchive.API.csproj b/FictionArchive.API/FictionArchive.API.csproj index 73638d7..302e65c 100644 --- a/FictionArchive.API/FictionArchive.API.csproj +++ b/FictionArchive.API/FictionArchive.API.csproj @@ -13,6 +13,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -20,6 +21,7 @@ + diff --git a/FictionArchive.API/Program.cs b/FictionArchive.API/Program.cs index 8ee5e37..f0608ee 100644 --- a/FictionArchive.API/Program.cs +++ b/FictionArchive.API/Program.cs @@ -12,7 +12,11 @@ public class Program #region Fusion Gateway - builder.Services.AddHttpClient("Fusion"); + builder.Services.AddHttpClient("Fusion") + .AddHeaderPropagation(opt => + { + opt.Headers.Add("Authorization"); + }); builder.Services .AddFusionGatewayServer() @@ -21,23 +25,29 @@ public class Program #endregion + // Add authentication + builder.Services.AddOidcAuthentication(builder.Configuration); + builder.Services.AddCors(options => { - options.AddPolicy("AllowAllOrigins", - builder => + options.AddPolicy("AllowFictionArchiveOrigins", + policyBuilder => { - builder.AllowAnyOrigin() + policyBuilder.WithOrigins("https://fictionarchive.orfl.xyz", "http://localhost:5173") .AllowAnyMethod() - .AllowAnyHeader(); + .AllowAnyHeader() + .AllowCredentials(); }); }); var app = builder.Build(); - app.UseCors("AllowAllOrigins"); + app.UseCors("AllowFictionArchiveOrigins"); app.MapHealthChecks("/healthz"); + app.UseHeaderPropagation(); + app.MapGraphQL(); app.RunWithGraphQLCommands(args); diff --git a/FictionArchive.API/appsettings.json b/FictionArchive.API/appsettings.json index 10f68b8..b581c6a 100644 --- a/FictionArchive.API/appsettings.json +++ b/FictionArchive.API/appsettings.json @@ -5,5 +5,15 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "OIDC": { + "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/", + "ClientId": "fictionarchive-api", + "Audience": "fictionarchive-api", + "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/", + "ValidateIssuer": true, + "ValidateAudience": true, + "ValidateLifetime": true, + "ValidateIssuerSigningKey": true + } } diff --git a/FictionArchive.Service.FileService/Controllers/S3ProxyController.cs b/FictionArchive.Service.FileService/Controllers/S3ProxyController.cs index 384095b..65a33fa 100644 --- a/FictionArchive.Service.FileService/Controllers/S3ProxyController.cs +++ b/FictionArchive.Service.FileService/Controllers/S3ProxyController.cs @@ -2,6 +2,7 @@ using System.Web; using Amazon.S3; using Amazon.S3.Model; using FictionArchive.Service.FileService.Models; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; @@ -10,6 +11,7 @@ namespace FictionArchive.Service.FileService.Controllers { [Route("api/{*path}")] [ApiController] + [Authorize] public class S3ProxyController : ControllerBase { private readonly AmazonS3Client _amazonS3Client; diff --git a/FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj b/FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj index 7e69402..436862b 100644 --- a/FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj +++ b/FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj @@ -21,6 +21,7 @@ + diff --git a/FictionArchive.Service.FileService/Program.cs b/FictionArchive.Service.FileService/Program.cs index 3decc95..7d8ac6d 100644 --- a/FictionArchive.Service.FileService/Program.cs +++ b/FictionArchive.Service.FileService/Program.cs @@ -34,6 +34,10 @@ public class Program #endregion + // Add authentication with cookie support + builder.Services.AddOidcCookieAuthentication(builder.Configuration); + builder.Services.AddFictionArchiveAuthorization(); + builder.Services.Configure(builder.Configuration.GetSection("ProxyConfiguration")); // Add S3 Client @@ -60,6 +64,9 @@ public class Program app.UseSwaggerUI(); } + app.UseAuthentication(); + app.UseAuthorization(); + app.MapHealthChecks("/healthz"); app.MapControllers(); diff --git a/FictionArchive.Service.FileService/appsettings.json b/FictionArchive.Service.FileService/appsettings.json index 0f6f7a4..ffe7eae 100644 --- a/FictionArchive.Service.FileService/appsettings.json +++ b/FictionArchive.Service.FileService/appsettings.json @@ -9,7 +9,7 @@ "BaseUrl": "https://localhost:7247/api" }, "RabbitMQ": { - "ConnectionString": "amqp://localhost2", + "ConnectionString": "amqp://localhost", "ClientIdentifier": "FileService" }, "S3": { @@ -18,5 +18,15 @@ "AccessKey": "REPLACE_ME", "SecretKey": "REPLACE_ME" }, + "OIDC": { + "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/", + "ClientId": "fictionarchive-files", + "Audience": "fictionarchive-api", + "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/", + "ValidateIssuer": true, + "ValidateAudience": true, + "ValidateLifetime": true, + "ValidateIssuerSigningKey": true + }, "AllowedHosts": "*" } diff --git a/FictionArchive.Service.NovelService/GraphQL/Mutation.cs b/FictionArchive.Service.NovelService/GraphQL/Mutation.cs index 15f92a7..af7724f 100644 --- a/FictionArchive.Service.NovelService/GraphQL/Mutation.cs +++ b/FictionArchive.Service.NovelService/GraphQL/Mutation.cs @@ -6,17 +6,20 @@ using FictionArchive.Service.NovelService.Models.SourceAdapters; using FictionArchive.Service.NovelService.Services; using FictionArchive.Service.NovelService.Services.SourceAdapters; using FictionArchive.Service.Shared.Services.EventBus; +using HotChocolate.Authorization; using Microsoft.EntityFrameworkCore; namespace FictionArchive.Service.NovelService.GraphQL; public class Mutation { + [Authorize] public async Task ImportNovel(string novelUrl, NovelUpdateService service) { return await service.QueueNovelImport(novelUrl); } + [Authorize] public async Task FetchChapterContents(uint novelId, uint chapterNumber, NovelUpdateService service) diff --git a/FictionArchive.Service.NovelService/GraphQL/Query.cs b/FictionArchive.Service.NovelService/GraphQL/Query.cs index 4539cfc..c594e2f 100644 --- a/FictionArchive.Service.NovelService/GraphQL/Query.cs +++ b/FictionArchive.Service.NovelService/GraphQL/Query.cs @@ -1,5 +1,6 @@ using FictionArchive.Service.NovelService.Models.Novels; using FictionArchive.Service.NovelService.Services; +using HotChocolate.Authorization; using HotChocolate.Data; using HotChocolate.Types; @@ -7,6 +8,7 @@ namespace FictionArchive.Service.NovelService.GraphQL; public class Query { + [Authorize] [UsePaging] [UseProjection] [UseFiltering] diff --git a/FictionArchive.Service.NovelService/Program.cs b/FictionArchive.Service.NovelService/Program.cs index ff9fd26..0b1f860 100644 --- a/FictionArchive.Service.NovelService/Program.cs +++ b/FictionArchive.Service.NovelService/Program.cs @@ -43,7 +43,8 @@ public class Program #region GraphQL - builder.Services.AddDefaultGraphQl(); + builder.Services.AddDefaultGraphQl() + .AddAuthorization(); #endregion @@ -75,6 +76,10 @@ public class Program builder.Services.AddHealthChecks(); + // Authentication & Authorization + builder.Services.AddOidcAuthentication(builder.Configuration); + builder.Services.AddFictionArchiveAuthorization(); + var app = builder.Build(); // Update database (skip in schema export mode) @@ -88,7 +93,10 @@ public class Program app.UseHttpsRedirection(); app.MapHealthChecks("/healthz"); - + + app.UseAuthentication(); + app.UseAuthorization(); + app.MapGraphQL(); app.RunWithGraphQLCommands(args); diff --git a/FictionArchive.Service.NovelService/appsettings.json b/FictionArchive.Service.NovelService/appsettings.json index 3bf5160..f287976 100644 --- a/FictionArchive.Service.NovelService/appsettings.json +++ b/FictionArchive.Service.NovelService/appsettings.json @@ -19,5 +19,15 @@ "ConnectionString": "amqp://localhost", "ClientIdentifier": "NovelService" }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "OIDC": { + "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/", + "ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh", + "Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh", + "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/", + "ValidateIssuer": true, + "ValidateAudience": true, + "ValidateLifetime": true, + "ValidateIssuerSigningKey": true + } } diff --git a/FictionArchive.Service.NovelService/subgraph-config.json b/FictionArchive.Service.NovelService/subgraph-config.json index 0d9b586..e00edb6 100644 --- a/FictionArchive.Service.NovelService/subgraph-config.json +++ b/FictionArchive.Service.NovelService/subgraph-config.json @@ -1,6 +1,6 @@ { "subgraph": "Novels", "http": { - "baseAddress": "http://localhost:5101/graphql" + "baseAddress": "https://localhost:7208/graphql" } } \ No newline at end of file diff --git a/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs b/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs index f1a6b65..d79582c 100644 --- a/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs +++ b/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs @@ -1,6 +1,8 @@ using System.Data; using FictionArchive.Service.SchedulerService.Models; using FictionArchive.Service.SchedulerService.Services; +using FictionArchive.Service.Shared.Constants; +using HotChocolate.Authorization; using HotChocolate.Types; using Quartz; @@ -10,18 +12,21 @@ public class Mutation { [Error] [Error] + [Authorize(Roles = [AuthorizationConstants.Roles.Admin])] public async Task ScheduleEventJob(string key, string description, string eventType, string eventData, string cronSchedule, JobManagerService jobManager) { return await jobManager.ScheduleEventJob(key, description, eventType, eventData, cronSchedule); } [Error] + [Authorize(Roles = [AuthorizationConstants.Roles.Admin])] public async Task RunJob(string jobKey, JobManagerService jobManager) { return await jobManager.TriggerJob(jobKey); } [Error] + [Authorize(Roles = [AuthorizationConstants.Roles.Admin])] public async Task DeleteJob(string jobKey, JobManagerService jobManager) { bool deleted = await jobManager.DeleteJob(jobKey); diff --git a/FictionArchive.Service.SchedulerService/Program.cs b/FictionArchive.Service.SchedulerService/Program.cs index 01e47d9..b1a9c92 100644 --- a/FictionArchive.Service.SchedulerService/Program.cs +++ b/FictionArchive.Service.SchedulerService/Program.cs @@ -17,10 +17,15 @@ public class Program var builder = WebApplication.CreateBuilder(args); // Services - builder.Services.AddDefaultGraphQl(); + builder.Services.AddDefaultGraphQl() + .AddAuthorization(); builder.Services.AddHealthChecks(); builder.Services.AddTransient(); - + + // Authentication & Authorization + builder.Services.AddOidcAuthentication(builder.Configuration); + builder.Services.AddFictionArchiveAuthorization(); + #region Database builder.Services.RegisterDbContext( @@ -87,7 +92,10 @@ public class Program app.UseHttpsRedirection(); app.MapHealthChecks("/healthz"); - + + app.UseAuthentication(); + app.UseAuthorization(); + app.MapGraphQL(); app.RunWithGraphQLCommands(args); diff --git a/FictionArchive.Service.SchedulerService/appsettings.json b/FictionArchive.Service.SchedulerService/appsettings.json index 6d027f1..043f775 100644 --- a/FictionArchive.Service.SchedulerService/appsettings.json +++ b/FictionArchive.Service.SchedulerService/appsettings.json @@ -12,5 +12,15 @@ "ConnectionStrings": { "DefaultConnection": "Host=localhost;Database=FictionArchive_SchedulerService;Username=postgres;password=postgres" }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "OIDC": { + "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/", + "ClientId": "fictionarchive-api", + "Audience": "fictionarchive-api", + "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/", + "ValidateIssuer": true, + "ValidateAudience": true, + "ValidateLifetime": true, + "ValidateIssuerSigningKey": true + } } diff --git a/FictionArchive.Service.Shared/Constants/AuthorizationConstants.cs b/FictionArchive.Service.Shared/Constants/AuthorizationConstants.cs new file mode 100644 index 0000000..79e7486 --- /dev/null +++ b/FictionArchive.Service.Shared/Constants/AuthorizationConstants.cs @@ -0,0 +1,15 @@ +namespace FictionArchive.Service.Shared.Constants; + +public static class AuthorizationConstants +{ + public static class Roles + { + public const string Admin = "admin"; + } + + public static class Policies + { + public const string Admin = "Admin"; + public const string User = "User"; + } +} diff --git a/FictionArchive.Service.Shared/Extensions/AuthenticationExtensions.cs b/FictionArchive.Service.Shared/Extensions/AuthenticationExtensions.cs new file mode 100644 index 0000000..6f1c3e4 --- /dev/null +++ b/FictionArchive.Service.Shared/Extensions/AuthenticationExtensions.cs @@ -0,0 +1,168 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; +using FictionArchive.Service.Shared.Constants; +using FictionArchive.Service.Shared.Models.Authentication; +using System.Linq; + +namespace FictionArchive.Service.Shared.Extensions; + +public static class AuthenticationExtensions +{ + public static IServiceCollection AddOidcAuthentication(this IServiceCollection services, IConfiguration configuration) + { + var oidcConfig = configuration.GetSection("OIDC").Get(); + + if (oidcConfig == null) + { + throw new InvalidOperationException("OIDC configuration is required but not found in app settings"); + } + + ValidateOidcConfiguration(oidcConfig); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = oidcConfig.Authority; + options.Audience = oidcConfig.Audience; + options.RequireHttpsMetadata = !string.IsNullOrEmpty(oidcConfig.Authority) && oidcConfig.Authority.StartsWith("https://"); + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = oidcConfig.ValidateIssuer, + ValidIssuer = oidcConfig.ValidIssuer, + ValidateAudience = oidcConfig.ValidateAudience, + ValidateLifetime = oidcConfig.ValidateLifetime, + ValidateIssuerSigningKey = oidcConfig.ValidateIssuerSigningKey, + ClockSkew = TimeSpan.FromMinutes(5) + }; + + options.Events = CreateLoggingJwtBearerEvents(); + }); + + return services; + } + + private static JwtBearerEvents CreateLoggingJwtBearerEvents(JwtBearerEvents? existingEvents = null) + { + return new JwtBearerEvents + { + OnMessageReceived = existingEvents?.OnMessageReceived ?? (_ => Task.CompletedTask), + OnAuthenticationFailed = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService() + .CreateLogger("JwtBearerAuthentication"); + + logger.LogWarning(context.Exception, "JWT authentication failed: {Message}", context.Exception.Message); + + return existingEvents?.OnAuthenticationFailed?.Invoke(context) ?? Task.CompletedTask; + }, + OnChallenge = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService() + .CreateLogger("JwtBearerAuthentication"); + + logger.LogDebug( + "JWT challenge issued. Error: {Error}, ErrorDescription: {ErrorDescription}", + context.Error, + context.ErrorDescription); + + return existingEvents?.OnChallenge?.Invoke(context) ?? Task.CompletedTask; + }, + OnTokenValidated = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService() + .CreateLogger("JwtBearerAuthentication"); + + logger.LogDebug( + "JWT token validated for subject: {Subject}", + context.Principal?.FindFirst("sub")?.Value ?? "unknown"); + + return existingEvents?.OnTokenValidated?.Invoke(context) ?? Task.CompletedTask; + } + }; + } + + public static IServiceCollection AddOidcCookieAuthentication(this IServiceCollection services, IConfiguration configuration, string cookieName = "fa_session") + { + var oidcConfig = configuration.GetSection("OIDC").Get(); + + if (oidcConfig == null) + { + throw new InvalidOperationException("OIDC configuration is required but not found in app settings"); + } + + ValidateOidcConfiguration(oidcConfig); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = oidcConfig.Authority; + options.Audience = oidcConfig.Audience; + options.RequireHttpsMetadata = !string.IsNullOrEmpty(oidcConfig.Authority) && oidcConfig.Authority.StartsWith("https://"); + + var cookieEvents = new JwtBearerEvents + { + OnMessageReceived = context => + { + // Try to get token from cookie first, then from Authorization header + if (context.Request.Cookies.ContainsKey(cookieName)) + { + context.Token = context.Request.Cookies[cookieName]; + } + + return Task.CompletedTask; + } + }; + options.Events = CreateLoggingJwtBearerEvents(cookieEvents); + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = oidcConfig.ValidateIssuer, + ValidIssuer = oidcConfig.ValidIssuer, + ValidateAudience = oidcConfig.ValidateAudience, + ValidateLifetime = oidcConfig.ValidateLifetime, + ValidateIssuerSigningKey = oidcConfig.ValidateIssuerSigningKey, + ClockSkew = TimeSpan.FromMinutes(5) + }; + }); + + return services; + } + + public static IServiceCollection AddFictionArchiveAuthorization(this IServiceCollection services) + { + services.AddAuthorizationBuilder() + .AddPolicy(AuthorizationConstants.Policies.Admin, policy => policy.RequireRole(AuthorizationConstants.Roles.Admin)) + .AddPolicy(AuthorizationConstants.Policies.User, policy => policy.RequireAuthenticatedUser()); + + return services; + } + + private static void ValidateOidcConfiguration(OidcConfiguration config) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(config.Authority)) + errors.Add("OIDC Authority is required"); + + if (string.IsNullOrWhiteSpace(config.ClientId)) + errors.Add("OIDC ClientId is required"); + + if (string.IsNullOrWhiteSpace(config.Audience)) + errors.Add("OIDC Audience is required"); + + if (!Uri.TryCreate(config.Authority, UriKind.Absolute, out var authorityUri)) + errors.Add($"OIDC Authority '{config.Authority}' is not a valid URI"); + else if (!authorityUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) && + !authorityUri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + errors.Add("OIDC Authority must use HTTPS unless running on localhost"); + + if (errors.Any()) + { + throw new InvalidOperationException($"OIDC configuration validation failed:{Environment.NewLine}{string.Join(Environment.NewLine, errors)}"); + } + } +} \ No newline at end of file diff --git a/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj b/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj index ee7c426..9e4fc17 100644 --- a/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj +++ b/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj @@ -9,6 +9,7 @@ + @@ -29,6 +30,7 @@ + diff --git a/FictionArchive.Service.Shared/Models/Authentication/OidcConfiguration.cs b/FictionArchive.Service.Shared/Models/Authentication/OidcConfiguration.cs new file mode 100644 index 0000000..a1ee624 --- /dev/null +++ b/FictionArchive.Service.Shared/Models/Authentication/OidcConfiguration.cs @@ -0,0 +1,13 @@ +namespace FictionArchive.Service.Shared.Models.Authentication; + +public class OidcConfiguration +{ + public string Authority { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public string? ValidIssuer { get; set; } + public bool ValidateIssuer { get; set; } = true; + public bool ValidateAudience { get; set; } = true; + public bool ValidateLifetime { get; set; } = true; + public bool ValidateIssuerSigningKey { get; set; } = true; +} \ No newline at end of file diff --git a/FictionArchive.Service.TranslationService/GraphQL/Mutation.cs b/FictionArchive.Service.TranslationService/GraphQL/Mutation.cs index d74a337..49873d9 100644 --- a/FictionArchive.Service.TranslationService/GraphQL/Mutation.cs +++ b/FictionArchive.Service.TranslationService/GraphQL/Mutation.cs @@ -5,15 +5,17 @@ using FictionArchive.Service.TranslationService.Models.Enums; using FictionArchive.Service.TranslationService.Services; using FictionArchive.Service.TranslationService.Services.Database; using FictionArchive.Service.TranslationService.Services.TranslationEngines; +using HotChocolate.Authorization; namespace FictionArchive.Service.TranslationService.GraphQL; public class Mutation { + [Authorize] public async Task TranslateText(string text, Language from, Language to, string translationEngineKey, TranslationEngineService translationEngineService) { var result = await translationEngineService.Translate(from, to, text, translationEngineKey); - + return result; } } \ No newline at end of file diff --git a/FictionArchive.Service.TranslationService/GraphQL/Query.cs b/FictionArchive.Service.TranslationService/GraphQL/Query.cs index 2b1c9f3..e753d54 100644 --- a/FictionArchive.Service.TranslationService/GraphQL/Query.cs +++ b/FictionArchive.Service.TranslationService/GraphQL/Query.cs @@ -2,19 +2,22 @@ using FictionArchive.Service.TranslationService.Models; using FictionArchive.Service.TranslationService.Models.Database; using FictionArchive.Service.TranslationService.Services.Database; using FictionArchive.Service.TranslationService.Services.TranslationEngines; +using HotChocolate.Authorization; using Microsoft.EntityFrameworkCore; namespace FictionArchive.Service.TranslationService.GraphQL; public class Query { + [Authorize] [UseFiltering] [UseSorting] public IEnumerable GetTranslationEngines(IEnumerable engines) { return engines.Select(engine => engine.Descriptor); } - + + [Authorize] [UsePaging] [UseProjection] [UseFiltering] diff --git a/FictionArchive.Service.TranslationService/Program.cs b/FictionArchive.Service.TranslationService/Program.cs index c110f78..9f4972c 100644 --- a/FictionArchive.Service.TranslationService/Program.cs +++ b/FictionArchive.Service.TranslationService/Program.cs @@ -50,7 +50,8 @@ public class Program #region GraphQL - builder.Services.AddDefaultGraphQl(); + builder.Services.AddDefaultGraphQl() + .AddAuthorization(); #endregion @@ -63,9 +64,13 @@ public class Program builder.Services.AddTransient(); builder.Services.AddTransient(); - + #endregion + // Authentication & Authorization + builder.Services.AddOidcAuthentication(builder.Configuration); + builder.Services.AddFictionArchiveAuthorization(); + var app = builder.Build(); // Update database (skip in schema export mode) @@ -79,7 +84,10 @@ public class Program app.UseHttpsRedirection(); app.MapHealthChecks("/healthz"); - + + app.UseAuthentication(); + app.UseAuthorization(); + app.MapGraphQL(); app.RunWithGraphQLCommands(args); diff --git a/FictionArchive.Service.TranslationService/appsettings.json b/FictionArchive.Service.TranslationService/appsettings.json index 4f0d4e1..c38b978 100644 --- a/FictionArchive.Service.TranslationService/appsettings.json +++ b/FictionArchive.Service.TranslationService/appsettings.json @@ -15,5 +15,15 @@ "ConnectionString": "amqp://localhost", "ClientIdentifier": "TranslationService" }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "OIDC": { + "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/", + "ClientId": "fictionarchive-api", + "Audience": "fictionarchive-api", + "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/", + "ValidateIssuer": true, + "ValidateAudience": true, + "ValidateLifetime": true, + "ValidateIssuerSigningKey": true + } } diff --git a/FictionArchive.Service.UserService/GraphQL/Mutation.cs b/FictionArchive.Service.UserService/GraphQL/Mutation.cs index dfd0409..4553b44 100644 --- a/FictionArchive.Service.UserService/GraphQL/Mutation.cs +++ b/FictionArchive.Service.UserService/GraphQL/Mutation.cs @@ -1,10 +1,13 @@ +using FictionArchive.Service.Shared.Constants; using FictionArchive.Service.UserService.Models.Database; using FictionArchive.Service.UserService.Services; +using HotChocolate.Authorization; namespace FictionArchive.Service.UserService.GraphQL; public class Mutation { + [Authorize(Roles = [AuthorizationConstants.Roles.Admin])] public async Task RegisterUser(string username, string email, string oAuthProviderId, string? inviterOAuthProviderId, UserManagementService userManagementService) { diff --git a/FictionArchive.Service.UserService/GraphQL/Query.cs b/FictionArchive.Service.UserService/GraphQL/Query.cs index c6ceb62..9049fa8 100644 --- a/FictionArchive.Service.UserService/GraphQL/Query.cs +++ b/FictionArchive.Service.UserService/GraphQL/Query.cs @@ -1,10 +1,12 @@ using FictionArchive.Service.UserService.Models.Database; using FictionArchive.Service.UserService.Services; +using HotChocolate.Authorization; namespace FictionArchive.Service.UserService.GraphQL; public class Query { + [Authorize] public async Task> GetUsers(UserManagementService userManagementService) { return userManagementService.GetUsers(); diff --git a/FictionArchive.Service.UserService/Program.cs b/FictionArchive.Service.UserService/Program.cs index efc54e9..1398ce9 100644 --- a/FictionArchive.Service.UserService/Program.cs +++ b/FictionArchive.Service.UserService/Program.cs @@ -1,3 +1,4 @@ +using FictionArchive.Common.Extensions; using FictionArchive.Service.Shared; using FictionArchive.Service.Shared.Extensions; using FictionArchive.Service.Shared.Services.EventBus.Implementations; @@ -15,7 +16,8 @@ public class Program var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args); var builder = WebApplication.CreateBuilder(args); - + builder.AddLocalAppsettings(); + #region Event Bus if (!isSchemaExport) @@ -31,7 +33,8 @@ public class Program #region GraphQL - builder.Services.AddDefaultGraphQl(); + builder.Services.AddDefaultGraphQl() + .AddAuthorization(); #endregion @@ -41,7 +44,11 @@ public class Program builder.Services.AddTransient(); builder.Services.AddHealthChecks(); - + + // Authentication & Authorization + builder.Services.AddOidcAuthentication(builder.Configuration); + builder.Services.AddFictionArchiveAuthorization(); + var app = builder.Build(); // Update database (skip in schema export mode) @@ -52,8 +59,11 @@ public class Program dbContext.UpdateDatabase(); } + app.UseAuthentication(); + app.UseAuthorization(); + app.MapGraphQL(); - + app.MapHealthChecks("/healthz"); app.RunWithGraphQLCommands(args); diff --git a/FictionArchive.Service.UserService/appsettings.json b/FictionArchive.Service.UserService/appsettings.json index ac07d77..f6ac2c3 100644 --- a/FictionArchive.Service.UserService/appsettings.json +++ b/FictionArchive.Service.UserService/appsettings.json @@ -12,5 +12,15 @@ "ConnectionString": "amqp://localhost", "ClientIdentifier": "UserService" }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "OIDC": { + "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/", + "ClientId": "fictionarchive-api", + "Audience": "fictionarchive-api", + "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/", + "ValidateIssuer": true, + "ValidateAudience": true, + "ValidateLifetime": true, + "ValidateIssuerSigningKey": true + } } diff --git a/docker-compose.yml b/docker-compose.yml index 2978d78..1590f28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -128,6 +128,9 @@ services: S3__AccessKey: ${S3_ACCESS_KEY} S3__SecretKey: ${S3_SECRET_KEY} Proxy__BaseUrl: https://files.orfl.xyz/api + OIDC__Authority: https://auth.orfl.xyz/application/o/fictionarchive/ + OIDC__ClientId: fictionarchive-files + OIDC__Audience: fictionarchive-api healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"] interval: 30s @@ -151,6 +154,9 @@ services: image: git.orfl.xyz/conco/fictionarchive-api:latest environment: ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq + OIDC__Authority: https://auth.orfl.xyz/application/o/fictionarchive/ + OIDC__ClientId: fictionarchive-api + OIDC__Audience: fictionarchive-api healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"] interval: 30s diff --git a/fictionarchive-web/src/auth/AuthContext.tsx b/fictionarchive-web/src/auth/AuthContext.tsx index 6ccb637..e46345e 100644 --- a/fictionarchive-web/src/auth/AuthContext.tsx +++ b/fictionarchive-web/src/auth/AuthContext.tsx @@ -2,6 +2,29 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import type { User } from 'oidc-client-ts' import { isOidcConfigured, userManager } from './oidcClient' +// Cookie management helper functions +function setCookieFromUser(user: User) { + if (!user?.access_token) return + + const isProduction = window.location.hostname !== 'localhost' + const domain = isProduction ? '.orfl.xyz' : undefined + const secure = isProduction + const sameSite = isProduction ? 'None' : 'Lax' + + // Set cookie with JWT token from user + const cookieValue = `fa_session=${user.access_token}; path=/; ${secure ? 'secure; ' : ''}samesite=${sameSite}${domain ? `; domain=${domain}` : ''}` + document.cookie = cookieValue +} + +function clearFaSessionCookie() { + const isProduction = window.location.hostname !== 'localhost' + const domain = isProduction ? '.orfl.xyz' : undefined + + // Clear cookie by setting expiration date in the past + const cookieValue = `fa_session=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT${domain ? `; domain=${domain}` : ''}` + document.cookie = cookieValue +} + type AuthContextValue = { user: User | null isLoading: boolean @@ -26,7 +49,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { userManager .getUser() .then((loadedUser) => { - if (!cancelled) setUser(loadedUser ?? null) + if (!cancelled) { + setUser(loadedUser ?? null) + if (loadedUser) { + setCookieFromUser(loadedUser) + } + } }) .finally(() => { if (!cancelled) setIsLoading(false) @@ -41,8 +69,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { const manager = userManager if (!manager) return - const handleLoaded = (nextUser: User) => setUser(nextUser) - const handleUnloaded = () => setUser(null) + const handleLoaded = (nextUser: User) => { + setUser(nextUser) + setCookieFromUser(nextUser) + } + const handleUnloaded = () => { + setUser(null) + clearFaSessionCookie() + } manager.events.addUserLoaded(handleLoaded) manager.events.addUserUnloaded(handleUnloaded) @@ -72,6 +106,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { .signinRedirectCallback() .then((nextUser) => { setUser(nextUser ?? null) + if (nextUser) { + setCookieFromUser(nextUser) + } }) .catch((error) => { console.error('Failed to complete sign-in redirect', error) @@ -103,6 +140,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { console.error('Failed to sign out via redirect, clearing local session instead.', error) await manager.removeUser() setUser(null) + clearFaSessionCookie() } }, [])