academia/src/backend/Host/Program.cs

345 lines
14 KiB
C#
Raw Normal View History

using Adapters.Driven.Persistence;
using Adapters.Driven.Persistence.Context;
using Adapters.Driving.Api.Extensions;
using Application;
using Application.Auth;
using dotenv.net;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens;
using Serilog;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using System.Threading.RateLimiting;
// Load .env file (searches up to root directory)
DotEnv.Load(options: new DotEnvOptions(
probeForEnv: true,
probeLevelsToSearch: 5
));
// Configure Serilog from appsettings + environment variables
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", optional: true)
.AddEnvironmentVariables()
.Build())
.CreateLogger();
try
{
Log.Information("Starting application");
var builder = WebApplication.CreateBuilder(args);
// Add environment variables to configuration
builder.Configuration.AddEnvironmentVariables();
// Build connection string from individual env vars if not provided directly
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
if (string.IsNullOrEmpty(connectionString))
{
var dbHost = Environment.GetEnvironmentVariable("DB_HOST") ?? "localhost";
var dbPort = Environment.GetEnvironmentVariable("DB_PORT") ?? "1433";
var dbName = Environment.GetEnvironmentVariable("DB_NAME") ?? "StudentEnrollment";
var dbUser = Environment.GetEnvironmentVariable("DB_USER") ?? "sa";
var dbPassword = Environment.GetEnvironmentVariable("DB_PASSWORD") ?? "";
connectionString = $"Server={dbHost},{dbPort};Database={dbName};User Id={dbUser};Password={dbPassword};TrustServerCertificate=True";
builder.Configuration["ConnectionStrings:DefaultConnection"] = connectionString;
}
builder.Host.UseSerilog();
// Add services
builder.Services.AddApplication();
builder.Services.AddPersistence(builder.Configuration);
builder.Services.AddGraphQLApi();
// Response Compression (Brotli preferred, then Gzip)
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
["application/json", "application/graphql-response+json"]);
});
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
options.Level = CompressionLevel.Fastest);
builder.Services.Configure<GzipCompressionProviderOptions>(options =>
options.Level = CompressionLevel.Fastest);
// CORS (configurable via CORS_ORIGINS env var, comma-separated)
var corsOrigins = Environment.GetEnvironmentVariable("CORS_ORIGINS")?.Split(',', StringSplitOptions.RemoveEmptyEntries)
?? ["http://localhost:4200"];
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
policy.WithOrigins(corsOrigins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials());
});
// JWT Authentication
var jwtSecretKey = Environment.GetEnvironmentVariable("JWT_SECRET_KEY")
?? "SuperSecretKeyForDevelopmentOnly_ChangeInProduction_AtLeast32Chars!";
builder.Services.Configure<JwtOptions>(opt =>
{
opt.SecretKey = jwtSecretKey;
opt.Issuer = Environment.GetEnvironmentVariable("JWT_ISSUER") ?? "StudentEnrollmentApi";
opt.Audience = Environment.GetEnvironmentVariable("JWT_AUDIENCE") ?? "StudentEnrollmentApp";
opt.ExpirationMinutes = int.TryParse(Environment.GetEnvironmentVariable("JWT_EXPIRATION_MINUTES"), out var exp) ? exp : 60;
});
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = Environment.GetEnvironmentVariable("JWT_ISSUER") ?? "StudentEnrollmentApi",
ValidAudience = Environment.GetEnvironmentVariable("JWT_AUDIENCE") ?? "StudentEnrollmentApp",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecretKey))
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"));
options.AddPolicy("StudentOrAdmin", policy => policy.RequireRole("Student", "Admin"));
});
// Output caching for read-heavy operations
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(policy => policy.Expire(TimeSpan.FromSeconds(10)));
options.AddPolicy("Subjects", policy => policy.Expire(TimeSpan.FromMinutes(5)));
options.AddPolicy("Professors", policy => policy.Expire(TimeSpan.FromMinutes(5)));
});
// Health checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>("database");
// Rate limiting (DoS prevention)
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("graphql", limiter =>
{
limiter.PermitLimit = 100;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiter.QueueLimit = 10;
});
options.AddFixedWindowLimiter("mutations", limiter =>
{
limiter.PermitLimit = 30;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 5;
});
});
var app = builder.Build();
// Verify database connection and apply migrations
await VerifyDatabaseConnectionAsync(app);
// Create admin user if not exists
await CreateAdminUserAsync(app);
async Task VerifyDatabaseConnectionAsync(WebApplication app)
{
var maxRetries = 10;
var delay = TimeSpan.FromSeconds(5);
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
Log.Information("Attempting database connection (attempt {Attempt}/{MaxRetries})...", attempt, maxRetries);
// Try to apply migrations - this will create the DB if it doesn't exist
Log.Information("Applying database migrations (this will create DB if needed)...");
await db.Database.MigrateAsync();
Log.Information("Database migrations applied successfully");
// Verify connection works after migrations
if (await db.Database.CanConnectAsync())
{
Log.Information("Database connection verified successfully");
return;
}
}
catch (Exception ex)
{
Log.Error(ex, "Database operation failed (attempt {Attempt}/{MaxRetries}): {ErrorMessage}",
attempt, maxRetries, ex.Message);
if (attempt == maxRetries)
{
Log.Fatal("Could not establish database connection after {MaxRetries} attempts. " +
"Please verify: DB_HOST={DbHost}, DB_PORT={DbPort}, DB_NAME={DbName}, DB_USER={DbUser}",
maxRetries,
Environment.GetEnvironmentVariable("DB_HOST") ?? "localhost",
Environment.GetEnvironmentVariable("DB_PORT") ?? "1433",
Environment.GetEnvironmentVariable("DB_NAME") ?? "StudentEnrollment",
Environment.GetEnvironmentVariable("DB_USER") ?? "sa");
}
}
if (attempt < maxRetries)
{
Log.Information("Waiting {Delay} seconds before next connection attempt...", delay.TotalSeconds);
await Task.Delay(delay);
}
}
}
async Task CreateAdminUserAsync(WebApplication app)
{
try
{
using var scope = app.Services.CreateScope();
var userRepo = scope.ServiceProvider.GetRequiredService<Domain.Ports.Repositories.IUserRepository>();
var passwordService = scope.ServiceProvider.GetRequiredService<Application.Auth.IPasswordService>();
var unitOfWork = scope.ServiceProvider.GetRequiredService<Domain.Ports.Repositories.IUnitOfWork>();
var adminUsername = Environment.GetEnvironmentVariable("ADMIN_USERNAME") ?? "admin";
var adminPassword = Environment.GetEnvironmentVariable("ADMIN_PASSWORD") ?? "admin123";
if (!await userRepo.ExistsAsync(adminUsername))
{
var passwordHash = passwordService.HashPassword(adminPassword);
var adminUser = Domain.Entities.User.Create(adminUsername, passwordHash, Domain.Entities.UserRoles.Admin);
await userRepo.AddAsync(adminUser);
await unitOfWork.SaveChangesAsync();
Log.Information("Admin user '{Username}' created successfully", adminUsername);
}
else
{
Log.Information("Admin user '{Username}' already exists", adminUsername);
}
}
catch (Exception ex)
{
Log.Warning(ex, "Could not create admin user: {Message}", ex.Message);
}
}
// Security headers (OWASP recommended)
app.Use(async (context, next) =>
{
var headers = context.Response.Headers;
headers.Append("X-Content-Type-Options", "nosniff");
headers.Append("X-Frame-Options", "DENY");
headers.Append("X-XSS-Protection", "0");
headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
headers.Append("Permissions-Policy", "geolocation=(), microphone=(), camera=()");
// CSP: More restrictive in production, relaxed for dev (GraphQL Playground needs unsafe-inline/eval)
var csp = app.Environment.IsDevelopment()
? "default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"font-src 'self'; " +
"connect-src 'self' http://localhost:* https://localhost:*; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'"
: "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self'; " +
"img-src 'self' data:; " +
"font-src 'self'; " +
"connect-src 'self'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'";
headers.Append("Content-Security-Policy", csp);
if (!context.Request.IsHttps && !app.Environment.IsDevelopment())
{
headers.Append("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
}
await next();
});
// Request logging (structured logs, excludes sensitive data)
app.UseSerilogRequestLogging(options =>
{
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
};
options.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
});
// Middleware order matters for performance
app.UseResponseCompression();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseOutputCache();
// Health check endpoint with JSON response
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = async (context, report) =>
{
context.Response.ContentType = "application/json";
var result = new
{
status = report.Status.ToString(),
timestamp = DateTime.UtcNow,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description,
duration = e.Value.Duration.TotalMilliseconds
})
};
await context.Response.WriteAsync(JsonSerializer.Serialize(result, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
}
});
// GraphQL endpoint with Banana Cake Pop playground and rate limiting
app.MapGraphQL()
.RequireRateLimiting("graphql")
.WithOptions(new HotChocolate.AspNetCore.GraphQLServerOptions
{
Tool = { Enable = app.Environment.IsDevelopment() }
});
await app.RunAsync();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
await Log.CloseAndFlushAsync();
}