feat: implement authentication system for API Gateway and FileService
Some checks failed
CI / build-backend (pull_request) Failing after 1m12s
CI / build-frontend (pull_request) Successful in 28s

- Add JWT Bearer token validation to API Gateway with restricted CORS
- Add cookie-based JWT validation to FileService for browser image requests
- Create shared authentication infrastructure in FictionArchive.Service.Shared
- Update frontend to set fa_session cookie after OIDC login
- Add [Authorize] attributes to GraphQL mutations with role-based restrictions
- Configure OIDC settings for both services in docker-compose

Implements FA-17: Authentication for microservices architecture
This commit is contained in:
Claude
2025-11-27 14:05:54 +00:00
parent 4412a1f658
commit 78612ea29d
14 changed files with 201 additions and 9 deletions

View File

@@ -20,6 +20,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" /> <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
</ItemGroup> </ItemGroup>
<!-- Builds the Fusion graph file before building the application itself (skipped in CI) --> <!-- Builds the Fusion graph file before building the application itself (skipped in CI) -->

View File

@@ -21,20 +21,28 @@ public class Program
#endregion #endregion
// Add authentication
builder.Services.AddOidcAuthentication(builder.Configuration);
builder.Services.AddFictionArchiveAuthorization();
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddPolicy("AllowAllOrigins", options.AddPolicy("AllowFictionArchiveOrigins",
builder => policyBuilder =>
{ {
builder.AllowAnyOrigin() policyBuilder.WithOrigins("https://fictionarchive.orfl.xyz", "http://localhost:5173")
.AllowAnyMethod() .AllowAnyMethod()
.AllowAnyHeader(); .AllowAnyHeader()
.AllowCredentials();
}); });
}); });
var app = builder.Build(); var app = builder.Build();
app.UseCors("AllowAllOrigins"); app.UseCors("AllowFictionArchiveOrigins");
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz"); app.MapHealthChecks("/healthz");

View File

@@ -5,5 +5,14 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fictionarchive/",
"ClientId": "fictionarchive-api",
"Audience": "fictionarchive-api",
"ValidateIssuer": true,
"ValidateAudience": true,
"ValidateLifetime": true,
"ValidateIssuerSigningKey": true
}
} }

View File

@@ -2,6 +2,7 @@ using System.Web;
using Amazon.S3; using Amazon.S3;
using Amazon.S3.Model; using Amazon.S3.Model;
using FictionArchive.Service.FileService.Models; using FictionArchive.Service.FileService.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -10,6 +11,7 @@ namespace FictionArchive.Service.FileService.Controllers
{ {
[Route("api/{*path}")] [Route("api/{*path}")]
[ApiController] [ApiController]
[Authorize]
public class S3ProxyController : ControllerBase public class S3ProxyController : ControllerBase
{ {
private readonly AmazonS3Client _amazonS3Client; private readonly AmazonS3Client _amazonS3Client;

View File

@@ -21,6 +21,7 @@
<PackageReference Include="AWSSDK.S3" Version="4.0.13.1" /> <PackageReference Include="AWSSDK.S3" Version="4.0.13.1" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" /> <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -34,6 +34,10 @@ public class Program
#endregion #endregion
// Add authentication with cookie support
builder.Services.AddOidcCookieAuthentication(builder.Configuration);
builder.Services.AddFictionArchiveAuthorization();
builder.Services.Configure<ProxyConfiguration>(builder.Configuration.GetSection("ProxyConfiguration")); builder.Services.Configure<ProxyConfiguration>(builder.Configuration.GetSection("ProxyConfiguration"));
// Add S3 Client // Add S3 Client
@@ -60,6 +64,9 @@ public class Program
app.UseSwaggerUI(); app.UseSwaggerUI();
} }
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz"); app.MapHealthChecks("/healthz");
app.MapControllers(); app.MapControllers();

View File

@@ -18,5 +18,14 @@
"AccessKey": "REPLACE_ME", "AccessKey": "REPLACE_ME",
"SecretKey": "REPLACE_ME" "SecretKey": "REPLACE_ME"
}, },
"OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fictionarchive/",
"ClientId": "fictionarchive-files",
"Audience": "fictionarchive-api",
"ValidateIssuer": true,
"ValidateAudience": true,
"ValidateLifetime": true,
"ValidateIssuerSigningKey": true
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View File

@@ -6,17 +6,20 @@ using FictionArchive.Service.NovelService.Models.SourceAdapters;
using FictionArchive.Service.NovelService.Services; using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.SourceAdapters; using FictionArchive.Service.NovelService.Services.SourceAdapters;
using FictionArchive.Service.Shared.Services.EventBus; using FictionArchive.Service.Shared.Services.EventBus;
using HotChocolate.Authorization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.GraphQL; namespace FictionArchive.Service.NovelService.GraphQL;
public class Mutation public class Mutation
{ {
[Authorize(Roles = "admin")]
public async Task<NovelUpdateRequestedEvent> ImportNovel(string novelUrl, NovelUpdateService service) public async Task<NovelUpdateRequestedEvent> ImportNovel(string novelUrl, NovelUpdateService service)
{ {
return await service.QueueNovelImport(novelUrl); return await service.QueueNovelImport(novelUrl);
} }
[Authorize]
public async Task<ChapterPullRequestedEvent> FetchChapterContents(uint novelId, public async Task<ChapterPullRequestedEvent> FetchChapterContents(uint novelId,
uint chapterNumber, uint chapterNumber,
NovelUpdateService service) NovelUpdateService service)

View File

@@ -1,6 +1,7 @@
using System.Data; using System.Data;
using FictionArchive.Service.SchedulerService.Models; using FictionArchive.Service.SchedulerService.Models;
using FictionArchive.Service.SchedulerService.Services; using FictionArchive.Service.SchedulerService.Services;
using HotChocolate.Authorization;
using HotChocolate.Types; using HotChocolate.Types;
using Quartz; using Quartz;
@@ -10,18 +11,21 @@ public class Mutation
{ {
[Error<DuplicateNameException>] [Error<DuplicateNameException>]
[Error<FormatException>] [Error<FormatException>]
[Authorize(Roles = "admin")]
public async Task<SchedulerJob> ScheduleEventJob(string key, string description, string eventType, string eventData, string cronSchedule, JobManagerService jobManager) public async Task<SchedulerJob> ScheduleEventJob(string key, string description, string eventType, string eventData, string cronSchedule, JobManagerService jobManager)
{ {
return await jobManager.ScheduleEventJob(key, description, eventType, eventData, cronSchedule); return await jobManager.ScheduleEventJob(key, description, eventType, eventData, cronSchedule);
} }
[Error<JobPersistenceException>] [Error<JobPersistenceException>]
[Authorize(Roles = "admin")]
public async Task<bool> RunJob(string jobKey, JobManagerService jobManager) public async Task<bool> RunJob(string jobKey, JobManagerService jobManager)
{ {
return await jobManager.TriggerJob(jobKey); return await jobManager.TriggerJob(jobKey);
} }
[Error<KeyNotFoundException>] [Error<KeyNotFoundException>]
[Authorize(Roles = "admin")]
public async Task<bool> DeleteJob(string jobKey, JobManagerService jobManager) public async Task<bool> DeleteJob(string jobKey, JobManagerService jobManager)
{ {
bool deleted = await jobManager.DeleteJob(jobKey); bool deleted = await jobManager.DeleteJob(jobKey);

View File

@@ -0,0 +1,91 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using FictionArchive.Service.Shared.Models.Authentication;
namespace FictionArchive.Service.Shared.Extensions;
public static class AuthenticationExtensions
{
public static IServiceCollection AddOidcAuthentication(this IServiceCollection services, IConfiguration configuration)
{
var oidcConfig = configuration.GetSection("OIDC").Get<OidcConfiguration>();
if (oidcConfig == null)
{
throw new InvalidOperationException("OIDC configuration is required but not found in app settings");
}
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,
ValidateAudience = oidcConfig.ValidateAudience,
ValidateLifetime = oidcConfig.ValidateLifetime,
ValidateIssuerSigningKey = oidcConfig.ValidateIssuerSigningKey,
ClockSkew = TimeSpan.FromMinutes(5)
};
});
return services;
}
public static IServiceCollection AddOidcCookieAuthentication(this IServiceCollection services, IConfiguration configuration, string cookieName = "fa_session")
{
var oidcConfig = configuration.GetSection("OIDC").Get<OidcConfiguration>();
if (oidcConfig == null)
{
throw new InvalidOperationException("OIDC configuration is required but not found in app settings");
}
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.Events = 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.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = oidcConfig.ValidateIssuer,
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("Admin", policy => policy.RequireRole("admin"))
.AddPolicy("User", policy => policy.RequireAuthenticatedUser());
return services;
}
}

View File

@@ -29,6 +29,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" /> <PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,12 @@
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 bool ValidateIssuer { get; set; } = true;
public bool ValidateAudience { get; set; } = true;
public bool ValidateLifetime { get; set; } = true;
public bool ValidateIssuerSigningKey { get; set; } = true;
}

View File

@@ -128,6 +128,9 @@ services:
S3__AccessKey: ${S3_ACCESS_KEY} S3__AccessKey: ${S3_ACCESS_KEY}
S3__SecretKey: ${S3_SECRET_KEY} S3__SecretKey: ${S3_SECRET_KEY}
Proxy__BaseUrl: https://files.orfl.xyz/api 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: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s interval: 30s
@@ -151,6 +154,9 @@ services:
image: git.orfl.xyz/conco/fictionarchive-api:latest image: git.orfl.xyz/conco/fictionarchive-api:latest
environment: environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq 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: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s interval: 30s

View File

@@ -2,6 +2,29 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import type { User } from 'oidc-client-ts' import type { User } from 'oidc-client-ts'
import { isOidcConfigured, userManager } from './oidcClient' 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 = { type AuthContextValue = {
user: User | null user: User | null
isLoading: boolean isLoading: boolean
@@ -26,7 +49,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
userManager userManager
.getUser() .getUser()
.then((loadedUser) => { .then((loadedUser) => {
if (!cancelled) setUser(loadedUser ?? null) if (!cancelled) {
setUser(loadedUser ?? null)
if (loadedUser) {
setCookieFromUser(loadedUser)
}
}
}) })
.finally(() => { .finally(() => {
if (!cancelled) setIsLoading(false) if (!cancelled) setIsLoading(false)
@@ -41,8 +69,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const manager = userManager const manager = userManager
if (!manager) return if (!manager) return
const handleLoaded = (nextUser: User) => setUser(nextUser) const handleLoaded = (nextUser: User) => {
const handleUnloaded = () => setUser(null) setUser(nextUser)
setCookieFromUser(nextUser)
}
const handleUnloaded = () => {
setUser(null)
clearFaSessionCookie()
}
manager.events.addUserLoaded(handleLoaded) manager.events.addUserLoaded(handleLoaded)
manager.events.addUserUnloaded(handleUnloaded) manager.events.addUserUnloaded(handleUnloaded)
@@ -72,6 +106,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
.signinRedirectCallback() .signinRedirectCallback()
.then((nextUser) => { .then((nextUser) => {
setUser(nextUser ?? null) setUser(nextUser ?? null)
if (nextUser) {
setCookieFromUser(nextUser)
}
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to complete sign-in redirect', 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) console.error('Failed to sign out via redirect, clearing local session instead.', error)
await manager.removeUser() await manager.removeUser()
setUser(null) setUser(null)
clearFaSessionCookie()
} }
}, []) }, [])