Add UK education verification and security fixes

Features:
- Add UK institution recognition (170+ universities)
- Add diploma mill detection (100+ blacklisted institutions)
- Add education verification service with date plausibility checks
- Add local file storage option (no Azure required)
- Add default admin user seeding on startup
- Enhance Serilog logging with file output

Security fixes:
- Fix path traversal vulnerability in LocalFileStorageService
- Fix open redirect in login endpoint (use LocalRedirect)
- Fix password validation message (12 chars, not 6)
- Fix login to use HTTP POST endpoint (avoid Blazor cookie issues)

Code improvements:
- Add CancellationToken propagation to CV parser
- Add shared helpers (JsonDefaults, DateHelpers, ScoreThresholds)
- Add IUserContextService for user ID extraction
- Parallelized company verification in ProcessCVCheckJob
- Add 28 unit tests for education verification

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 16:45:43 +01:00
parent c6d52a38b2
commit f1ccd217d8
35 changed files with 1791 additions and 415 deletions

View File

@@ -32,15 +32,19 @@ try
// Add Infrastructure services (DbContext, Hangfire, HttpClients, Services)
builder.Services.AddInfrastructure(builder.Configuration);
// Add Identity
// Add Identity with secure password requirements
builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(options =>
{
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 6;
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();
@@ -62,6 +66,26 @@ try
var app = builder.Build();
// Seed default admin user
using (var scope = app.Services.CreateScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var defaultEmail = "admin@truecv.local";
var defaultPassword = "TrueCV_Admin123!";
if (await userManager.FindByEmailAsync(defaultEmail) == null)
{
var adminUser = new ApplicationUser
{
UserName = defaultEmail,
Email = defaultEmail,
EmailConfirmed = true
};
await userManager.CreateAsync(adminUser, defaultPassword);
Log.Information("Created default admin user: {Email}", defaultEmail);
}
}
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
@@ -98,6 +122,44 @@ try
});
}
// 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.");
}
});
// Logout endpoint
app.MapPost("/account/logout", async (SignInManager<ApplicationUser> signInManager) =>
{