- Renamed all directories (TrueCV.* -> RealCV.*) - Renamed all project files (.csproj) - Renamed solution file (TrueCV.sln -> RealCV.sln) - Updated all namespaces in C# and Razor files - Updated project references - Updated CSS variable names 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
231 lines
8.0 KiB
C#
231 lines
8.0 KiB
C#
using System.Threading.RateLimiting;
|
|
using Hangfire;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Serilog;
|
|
using RealCV.Infrastructure;
|
|
using RealCV.Infrastructure.Data;
|
|
using RealCV.Infrastructure.Identity;
|
|
using RealCV.Web;
|
|
using RealCV.Web.Components;
|
|
using RealCV.Web.Services;
|
|
|
|
// Configure Serilog
|
|
Log.Logger = new LoggerConfiguration()
|
|
.WriteTo.Console()
|
|
.CreateBootstrapLogger();
|
|
|
|
try
|
|
{
|
|
Log.Information("Starting RealCV web application");
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Configure Serilog from appsettings
|
|
builder.Host.UseSerilog((context, services, configuration) => configuration
|
|
.ReadFrom.Configuration(context.Configuration)
|
|
.ReadFrom.Services(services)
|
|
.Enrich.FromLogContext());
|
|
|
|
// Add services to the container.
|
|
builder.Services.AddRazorComponents()
|
|
.AddInteractiveServerComponents();
|
|
|
|
// Add Infrastructure services (DbContext, Hangfire, HttpClients, Services)
|
|
builder.Services.AddInfrastructure(builder.Configuration);
|
|
|
|
// Add Web services
|
|
builder.Services.AddScoped<IPdfReportService, PdfReportService>();
|
|
|
|
// Add Identity with secure password requirements
|
|
builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(options =>
|
|
{
|
|
options.Password.RequireDigit = true;
|
|
options.Password.RequireLowercase = true;
|
|
options.Password.RequireUppercase = true;
|
|
options.Password.RequireNonAlphanumeric = true;
|
|
options.Password.RequiredLength = 12;
|
|
options.Password.RequiredUniqueChars = 4;
|
|
options.SignIn.RequireConfirmedAccount = false;
|
|
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
|
|
options.Lockout.MaxFailedAccessAttempts = 5;
|
|
options.Lockout.AllowedForNewUsers = true;
|
|
})
|
|
.AddEntityFrameworkStores<ApplicationDbContext>()
|
|
.AddDefaultTokenProviders();
|
|
|
|
builder.Services.ConfigureApplicationCookie(options =>
|
|
{
|
|
options.LoginPath = "/account/login";
|
|
options.LogoutPath = "/account/logout";
|
|
options.AccessDeniedPath = "/account/login";
|
|
});
|
|
|
|
// Add Cascading Authentication State
|
|
builder.Services.AddCascadingAuthenticationState();
|
|
builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingIdentityAuthenticationStateProvider<ApplicationUser>>();
|
|
|
|
// Add health checks
|
|
builder.Services.AddHealthChecks()
|
|
.AddDbContextCheck<ApplicationDbContext>("database");
|
|
|
|
// Add rate limiting for login endpoint
|
|
builder.Services.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
options.AddFixedWindowLimiter("login", limiterOptions =>
|
|
{
|
|
limiterOptions.PermitLimit = 5;
|
|
limiterOptions.Window = TimeSpan.FromMinutes(1);
|
|
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
|
limiterOptions.QueueLimit = 0;
|
|
});
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
// Seed default admin user and clear company cache
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
|
var defaultEmail = builder.Configuration["DefaultAdmin:Email"];
|
|
var defaultPassword = builder.Configuration["DefaultAdmin:Password"];
|
|
|
|
if (!string.IsNullOrEmpty(defaultEmail) && !string.IsNullOrEmpty(defaultPassword))
|
|
{
|
|
if (await userManager.FindByEmailAsync(defaultEmail) == null)
|
|
{
|
|
var adminUser = new ApplicationUser
|
|
{
|
|
UserName = defaultEmail,
|
|
Email = defaultEmail,
|
|
EmailConfirmed = true
|
|
};
|
|
var result = await userManager.CreateAsync(adminUser, defaultPassword);
|
|
if (result.Succeeded)
|
|
{
|
|
Log.Information("Created default admin user: {Email}", defaultEmail);
|
|
}
|
|
else
|
|
{
|
|
Log.Warning("Failed to create admin user: {Errors}", string.Join(", ", result.Errors.Select(e => e.Description)));
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Log.Information("No default admin credentials configured - skipping admin user seeding");
|
|
}
|
|
|
|
// Clear company cache on startup to ensure fresh API lookups
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
var cacheCount = await dbContext.CompanyCache.CountAsync();
|
|
if (cacheCount > 0)
|
|
{
|
|
dbContext.CompanyCache.RemoveRange(dbContext.CompanyCache);
|
|
await dbContext.SaveChangesAsync();
|
|
Log.Information("Cleared {Count} entries from company cache", cacheCount);
|
|
}
|
|
}
|
|
|
|
// Configure the HTTP request pipeline.
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
|
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
|
app.UseHsts();
|
|
}
|
|
|
|
// Only use HTTPS redirection when not running in Docker/container
|
|
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")))
|
|
{
|
|
// Running in container - skip HTTPS redirect (handled by reverse proxy)
|
|
}
|
|
else
|
|
{
|
|
app.UseHttpsRedirection();
|
|
}
|
|
|
|
app.UseStaticFiles();
|
|
app.UseAntiforgery();
|
|
|
|
// Add Serilog request logging
|
|
app.UseSerilogRequestLogging();
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseRateLimiter();
|
|
|
|
// Add Hangfire Dashboard (only in development, requires authentication)
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
|
{
|
|
Authorization = [new HangfireAuthorizationFilter()]
|
|
});
|
|
}
|
|
|
|
// Login endpoint
|
|
app.MapPost("/account/perform-login", async (
|
|
HttpContext context,
|
|
SignInManager<ApplicationUser> signInManager) =>
|
|
{
|
|
var form = await context.Request.ReadFormAsync();
|
|
var email = form["email"].ToString();
|
|
var password = form["password"].ToString();
|
|
var rememberMe = form["rememberMe"].ToString() == "true";
|
|
var returnUrl = form["returnUrl"].ToString();
|
|
|
|
Log.Information("Login attempt for {Email}", email);
|
|
|
|
// Validate returnUrl is local to prevent open redirect attacks
|
|
if (string.IsNullOrEmpty(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative) || returnUrl.StartsWith("//"))
|
|
{
|
|
returnUrl = "/dashboard";
|
|
}
|
|
|
|
var result = await signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true);
|
|
|
|
if (result.Succeeded)
|
|
{
|
|
Log.Information("User {Email} logged in successfully", email);
|
|
return Results.LocalRedirect(returnUrl);
|
|
}
|
|
else if (result.IsLockedOut)
|
|
{
|
|
Log.Warning("User {Email} account is locked out", email);
|
|
return Results.Redirect("/account/login?error=Account+locked.+Try+again+later.");
|
|
}
|
|
else
|
|
{
|
|
Log.Warning("Failed login attempt for {Email}", email);
|
|
return Results.Redirect("/account/login?error=Invalid+email+or+password.");
|
|
}
|
|
}).RequireRateLimiting("login");
|
|
|
|
// Logout endpoint
|
|
app.MapPost("/account/logout", async (SignInManager<ApplicationUser> signInManager) =>
|
|
{
|
|
await signInManager.SignOutAsync();
|
|
return Results.Redirect("/");
|
|
}).RequireAuthorization();
|
|
|
|
// Health check endpoint
|
|
app.MapHealthChecks("/health");
|
|
|
|
app.MapRazorComponents<App>()
|
|
.AddInteractiveServerRenderMode();
|
|
|
|
app.Run();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Fatal(ex, "Application terminated unexpectedly");
|
|
}
|
|
finally
|
|
{
|
|
Log.CloseAndFlush();
|
|
}
|