diff --git a/backend/Extensions/ApplicationBuilderExtensions.cs b/backend/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..5139dee --- /dev/null +++ b/backend/Extensions/ApplicationBuilderExtensions.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; +using Nexus.Api.Data; +using Nexus.Api.Helpers; +using Nexus.Api.Middleware; +using Nexus.Api.Services; + +namespace Nexus.Api.Extensions; + +/// +/// Extension methods for configuring the Nexus application pipeline and startup. +/// +public static class ApplicationBuilderExtensions +{ + /// + /// Applies pending EF Core migrations and seeds the initial owner account if none exist. + /// + public static async Task EnsureDatabaseAsync(this WebApplication app) + { + var configuration = app.Configuration; + + await using (var scope = app.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + + var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant(); + var ownerPassword = configuration["Owner:Password"]; + var ownerDisplayName = configuration["Owner:DisplayName"]?.Trim(); + var hasUsers = await db.Users.AnyAsync(); + + if (!hasUsers) + { + if (string.IsNullOrWhiteSpace(ownerEmail)) + throw new InvalidOperationException("Owner:Email is required for initial setup."); + + var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName) + ? PasswordHelper.BuildOwnerDisplayName(ownerEmail) + : ownerDisplayName; + var initialPassword = string.IsNullOrWhiteSpace(ownerPassword) + ? PasswordHelper.GenerateTemporaryPassword() + : ownerPassword; + + if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10) + throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly."); + + db.Users.Add(new NexusUser + { + Email = ownerEmail, + NormalizedEmail = AuthService.NormalizeEmail(ownerEmail), + DisplayName = initialDisplayName, + PasswordHash = PasswordSecurity.Hash(initialPassword), + Role = "owner" + }); + await db.SaveChangesAsync(); + + if (string.IsNullOrWhiteSpace(ownerPassword)) + { + Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}"); + } + } + } + } + + /// + /// Configures the HTTP middleware pipeline: forwarded headers, rate limiting, auth, security headers, and Swagger in development. + /// + public static IApplicationBuilder UseNexusPipeline(this IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseForwardedHeaders(); + app.UseRateLimiter(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseSecurityHeaders(); + + if (env.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + return app; + } +} diff --git a/backend/Extensions/ServiceCollectionExtensions.cs b/backend/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..4b7d0d9 --- /dev/null +++ b/backend/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,214 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.IdentityModel.Tokens; +using Nexus.Api.Data; +using Nexus.Api.Integrations; +using Nexus.Api.Repositories; +using Nexus.Api.Routing; +using Nexus.Api.Services; +using System.IdentityModel.Tokens.Jwt; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.RateLimiting; + +namespace Nexus.Api.Extensions; + +/// +/// Extension methods for registering Nexus application services in the DI container. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Configures JWT authentication, authorization, and antiforgery. + /// + public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration) + { + var jwtKey = configuration["Jwt:Key"]; + var jwtIssuer = configuration["Jwt:Issuer"] ?? "nexus"; + var jwtAudience = configuration["Jwt:Audience"] ?? "nexus-web"; + if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32) + throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes."); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.MapInboundClaims = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtIssuer, + ValidAudience = jwtAudience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + NameClaimType = JwtRegisteredClaimNames.Sub, + RoleClaimType = System.Security.Claims.ClaimTypes.Role, + ClockSkew = TimeSpan.FromSeconds(30) + }; + }); + + services.AddAuthorization(); + services.AddAntiforgery(options => + { + options.HeaderName = "X-CSRF-TOKEN"; + options.Cookie.Name = "nexus-csrf"; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.Cookie.HttpOnly = false; + }); + + return services; + } + + /// + /// Configures rate limiting policies (auth and agents). + /// + public static IServiceCollection AddNexusRateLimiting(this IServiceCollection services) + { + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + })); + + options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 30, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + })); + }); + + return services; + } + + /// + /// Configures forwarded headers for reverse proxy scenarios. + /// + public static IServiceCollection AddNexusForwardedHeaders(this IServiceCollection services) + { + services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); + }); + + return services; + } + + /// + /// Configures Swagger and JSON serialization options. + /// + public static IServiceCollection AddNexusSwagger(this IServiceCollection services) + { + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(); + services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); + + return services; + } + + /// + /// Registers the Entity Framework Core DbContext with Npgsql. + /// + public static IServiceCollection AddNexusDatabase(this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext(options => + options.UseNpgsql(configuration.GetConnectionString("Nexus")) + .ConfigureWarnings(w => w.Ignore( + Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))); + + return services; + } + + /// + /// Registers typed and named HTTP clients for OpenClaw integration. + /// + public static IServiceCollection AddNexusHttpClients(this IServiceCollection services, IConfiguration configuration) + { + services.AddHttpClient(client => + { + client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"] + ?? "http://127.0.0.1:18789"); + client.Timeout = TimeSpan.FromSeconds(120); + }); + + services.AddHttpClient("gateway", client => + { + client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"] + ?? "http://127.0.0.1:18789"); + client.Timeout = TimeSpan.FromSeconds(120); + }); + + services.AddHttpClient(client => + { + client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"] + ?? "http://127.0.0.1:18789"); + client.Timeout = TimeSpan.FromSeconds(120); + }); + + return services; + } + + /// + /// Registers application domain services (transient, scoped, singleton). + /// + public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services) + { + services.AddTransient(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + + return services; + } + + /// + /// Registers data repositories. + /// + public static IServiceCollection AddNexusRepositories(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + /// + /// Configures health checks (PostgreSQL connectivity and runtime status). + /// + public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration) + { + services.AddHealthChecks() + .AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"]) + .AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]); + + return services; + } +} diff --git a/backend/Helpers/PasswordHelper.cs b/backend/Helpers/PasswordHelper.cs new file mode 100644 index 0000000..09eee7b --- /dev/null +++ b/backend/Helpers/PasswordHelper.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; + +namespace Nexus.Api.Helpers; + +/// +/// Helper methods for password generation and name construction. +/// +public static class PasswordHelper +{ + /// + /// Generates a cryptographically random temporary password (30 chars, URL-safe base64). + /// + public static string GenerateTemporaryPassword() + => Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + /// + /// Builds a human-readable display name from an email address. + /// + public static string BuildOwnerDisplayName(string email) + { + var localPart = email.Split('@', 2)[0].Trim(); + if (string.IsNullOrWhiteSpace(localPart)) return "Owner"; + + var words = localPart + .Replace('.', ' ') + .Replace('_', ' ') + .Replace('-', ' ') + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant()); + + var displayName = string.Join(' ', words); + return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName; + } +} diff --git a/backend/Program.cs b/backend/Program.cs index e96fa1b..e08a69f 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,234 +1,26 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.RateLimiting; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.IdentityModel.Tokens; -using Nexus.Api.Data; -using Nexus.Api.Integrations; -using Nexus.Api.Middleware; -using Nexus.Api.Repositories; -using Nexus.Api.Routing; -using Nexus.Api.Services; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json.Serialization; -using System.Threading.RateLimiting; +using Nexus.Api.Extensions; var builder = WebApplication.CreateBuilder(args); -// --- JWT Configuration --- -var jwtKey = builder.Configuration["Jwt:Key"]; -var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus"; -var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "nexus-web"; -if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32) - throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes."); - -builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.MapInboundClaims = false; - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - ValidIssuer = jwtIssuer, - ValidAudience = jwtAudience, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), - NameClaimType = JwtRegisteredClaimNames.Sub, - RoleClaimType = System.Security.Claims.ClaimTypes.Role, - ClockSkew = TimeSpan.FromSeconds(30) - }; - }); - -builder.Services.AddAuthorization(); -builder.Services.AddAntiforgery(options => -{ - options.HeaderName = "X-CSRF-TOKEN"; - options.Cookie.Name = "nexus-csrf"; - options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; - options.Cookie.HttpOnly = false; -}); - -// --- Rate Limiting --- -builder.Services.AddRateLimiter(options => -{ - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter( - context.Connection.RemoteIpAddress?.ToString() ?? "unknown", - _ => new FixedWindowRateLimiterOptions - { - PermitLimit = 5, - Window = TimeSpan.FromMinutes(1), - QueueLimit = 0, - AutoReplenishment = true - })); - - options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter( - context.Connection.RemoteIpAddress?.ToString() ?? "unknown", - _ => new FixedWindowRateLimiterOptions - { - PermitLimit = 30, - Window = TimeSpan.FromMinutes(1), - QueueLimit = 0, - AutoReplenishment = true - })); -}); - -// --- Forwarded Headers --- -builder.Services.Configure(options => -{ - options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - options.KnownIPNetworks.Clear(); - options.KnownProxies.Clear(); -}); - -// --- Swagger & JSON --- -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); -builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); - -// --- Database --- -builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus")) - .ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))); - -// --- HTTP Clients --- -builder.Services.AddHttpClient(client => -{ - client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"] - ?? "http://127.0.0.1:18789"); - client.Timeout = TimeSpan.FromSeconds(120); -}); - -builder.Services.AddHttpClient("gateway", client => -{ - client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"] - ?? "http://127.0.0.1:18789"); - client.Timeout = TimeSpan.FromSeconds(120); -}); - -builder.Services.AddHttpClient(client => -{ - client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"] - ?? "http://127.0.0.1:18789"); - client.Timeout = TimeSpan.FromSeconds(120); -}); - -// --- Application Services --- -builder.Services.AddTransient(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddScoped(); - -// --- Repositories --- -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// --- Health Checks --- -builder.Services.AddHealthChecks() - .AddNpgSql(builder.Configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"]) - .AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]); - -// --- Controllers --- +// --- Service Registration --- +builder.Services.AddNexusAuth(builder.Configuration); +builder.Services.AddNexusRateLimiting(); +builder.Services.AddNexusForwardedHeaders(); +builder.Services.AddNexusSwagger(); +builder.Services.AddNexusDatabase(builder.Configuration); +builder.Services.AddNexusHttpClients(builder.Configuration); +builder.Services.AddNexusApplicationServices(); +builder.Services.AddNexusRepositories(); +builder.Services.AddNexusHealthChecks(builder.Configuration); builder.Services.AddControllers(); var app = builder.Build(); -// --- Database Migration & Owner Seeding --- -await using (var scope = app.Services.CreateAsyncScope()) -{ - var db = scope.ServiceProvider.GetRequiredService(); - await db.Database.MigrateAsync(); - - var ownerEmail = builder.Configuration["Owner:Email"]?.Trim().ToLowerInvariant(); - var ownerPassword = builder.Configuration["Owner:Password"]; - var ownerDisplayName = builder.Configuration["Owner:DisplayName"]?.Trim(); - var hasUsers = await db.Users.AnyAsync(); - - if (!hasUsers) - { - if (string.IsNullOrWhiteSpace(ownerEmail)) - throw new InvalidOperationException("Owner:Email is required for initial setup."); - - var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName) - ? BuildOwnerDisplayName(ownerEmail) - : ownerDisplayName; - var initialPassword = string.IsNullOrWhiteSpace(ownerPassword) - ? GenerateTemporaryPassword() - : ownerPassword; - - if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10) - throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly."); - - db.Users.Add(new NexusUser - { - Email = ownerEmail, - NormalizedEmail = AuthService.NormalizeEmail(ownerEmail), - DisplayName = initialDisplayName, - PasswordHash = PasswordSecurity.Hash(initialPassword), - Role = "owner" - }); - await db.SaveChangesAsync(); - - if (string.IsNullOrWhiteSpace(ownerPassword)) - { - Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}"); - } - } -} +// --- Database Migration & Seeding --- +await app.EnsureDatabaseAsync(); // --- Middleware Pipeline --- -app.UseForwardedHeaders(); -app.UseRateLimiter(); -app.UseAuthentication(); -app.UseAuthorization(); -app.UseSecurityHeaders(); - -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} +app.UseNexusPipeline(app.Environment); app.MapControllers(); app.Run(); - -// --- Helpers --- - -static string GenerateTemporaryPassword() - => Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - -static string BuildOwnerDisplayName(string email) -{ - var localPart = email.Split('@', 2)[0].Trim(); - if (string.IsNullOrWhiteSpace(localPart)) return "Owner"; - - var words = localPart - .Replace('.', ' ') - .Replace('_', ' ') - .Replace('-', ' ') - .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant()); - - var displayName = string.Join(' ', words); - return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName; -}