feat: implement authentication system for API Gateway and FileService #34
@@ -20,6 +20,7 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Builds the Fusion graph file before building the application itself (skipped in CI) -->
|
||||
|
||||
@@ -21,20 +21,28 @@ public class Program
|
||||
|
||||
#endregion
|
||||
|
||||
// Add authentication
|
||||
builder.Services.AddOidcAuthentication(builder.Configuration);
|
||||
builder.Services.AddFictionArchiveAuthorization();
|
||||
|
||||
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.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
|
||||
@@ -5,5 +5,14 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<PackageReference Include="AWSSDK.S3" Version="4.0.13.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -34,6 +34,10 @@ public class Program
|
||||
|
||||
#endregion
|
||||
|
||||
// Add authentication with cookie support
|
||||
builder.Services.AddOidcCookieAuthentication(builder.Configuration);
|
||||
builder.Services.AddFictionArchiveAuthorization();
|
||||
|
||||
builder.Services.Configure<ProxyConfiguration>(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();
|
||||
|
||||
@@ -18,5 +18,14 @@
|
||||
"AccessKey": "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": "*"
|
||||
}
|
||||
|
||||
@@ -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(Roles = "admin")]
|
||||
public async Task<NovelUpdateRequestedEvent> ImportNovel(string novelUrl, NovelUpdateService service)
|
||||
{
|
||||
return await service.QueueNovelImport(novelUrl);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
public async Task<ChapterPullRequestedEvent> FetchChapterContents(uint novelId,
|
||||
uint chapterNumber,
|
||||
NovelUpdateService service)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Data;
|
||||
using FictionArchive.Service.SchedulerService.Models;
|
||||
using FictionArchive.Service.SchedulerService.Services;
|
||||
using HotChocolate.Authorization;
|
||||
using HotChocolate.Types;
|
||||
using Quartz;
|
||||
|
||||
@@ -10,18 +11,21 @@ public class Mutation
|
||||
{
|
||||
[Error<DuplicateNameException>]
|
||||
[Error<FormatException>]
|
||||
[Authorize(Roles = "admin")]
|
||||
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);
|
||||
}
|
||||
|
||||
[Error<JobPersistenceException>]
|
||||
[Authorize(Roles = "admin")]
|
||||
public async Task<bool> RunJob(string jobKey, JobManagerService jobManager)
|
||||
{
|
||||
return await jobManager.TriggerJob(jobKey);
|
||||
}
|
||||
|
||||
[Error<KeyNotFoundException>]
|
||||
[Authorize(Roles = "admin")]
|
||||
public async Task<bool> DeleteJob(string jobKey, JobManagerService jobManager)
|
||||
{
|
||||
bool deleted = await jobManager.DeleteJob(jobKey);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user