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

@@ -1,7 +1,6 @@
@page "/account/login"
@using TrueCV.Web.Components.Layout
@layout MainLayout
@rendermode InteractiveServer
@using Microsoft.AspNetCore.Identity
@using TrueCV.Infrastructure.Identity
@@ -26,50 +25,40 @@
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<div class="alert alert-danger" role="alert">
@_errorMessage
<button type="button" class="btn-close" @onclick="() => _errorMessage = null" aria-label="Close"></button>
</div>
}
<EditForm Model="_model" OnValidSubmit="HandleLogin" FormName="login">
<DataAnnotationsValidator />
<form method="post" action="/account/perform-login">
<AntiforgeryToken />
<input type="hidden" name="returnUrl" value="@ReturnUrl" />
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<InputText id="email" class="form-control form-control-lg" @bind-Value="_model.Email"
placeholder="name@example.com" />
<ValidationMessage For="() => _model.Email" class="text-danger" />
<input id="email" name="email" type="email" class="form-control form-control-lg"
placeholder="name@example.com" required />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<InputText id="password" type="password" class="form-control form-control-lg"
@bind-Value="_model.Password" placeholder="Enter your password" />
<ValidationMessage For="() => _model.Password" class="text-danger" />
<input id="password" name="password" type="password" class="form-control form-control-lg"
placeholder="Enter your password" required />
</div>
<div class="mb-3 form-check">
<InputCheckbox id="rememberMe" class="form-check-input" @bind-Value="_model.RememberMe" />
<input id="rememberMe" name="rememberMe" type="checkbox" class="form-check-input" value="true" />
<label class="form-check-label" for="rememberMe">
Remember me
</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg" disabled="@_isLoading">
@if (_isLoading)
{
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span>Signing in...</span>
}
else
{
<span>Sign In</span>
}
<button type="submit" class="btn btn-primary btn-lg">
Sign In
</button>
</div>
</EditForm>
</form>
<hr class="my-4" />
@@ -86,63 +75,16 @@
</div>
@code {
private LoginModel _model = new();
private bool _isLoading;
private string? _errorMessage;
[SupplyParameterFromQuery]
public string? ReturnUrl { get; set; }
private async Task HandleLogin()
[SupplyParameterFromQuery(Name = "error")]
public string? Error { get; set; }
protected override void OnInitialized()
{
_isLoading = true;
_errorMessage = null;
try
{
var result = await SignInManager.PasswordSignInAsync(
_model.Email,
_model.Password,
_model.RememberMe,
lockoutOnFailure: false);
if (result.Succeeded)
{
var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/dashboard" : ReturnUrl;
NavigationManager.NavigateTo(returnUrl, forceLoad: true);
}
else if (result.IsLockedOut)
{
_errorMessage = "This account has been locked out. Please try again later.";
}
else if (result.IsNotAllowed)
{
_errorMessage = "This account is not allowed to sign in.";
}
else
{
_errorMessage = "Invalid email or password.";
}
}
catch (Exception ex)
{
_errorMessage = $"An error occurred: {ex.Message}";
}
finally
{
_isLoading = false;
}
}
private sealed class LoginModel
{
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
[System.ComponentModel.DataAnnotations.EmailAddress(ErrorMessage = "Invalid email format")]
public string Email { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
public string Password { get; set; } = string.Empty;
public bool RememberMe { get; set; }
_errorMessage = Error;
}
}

View File

@@ -48,7 +48,7 @@
<InputText id="password" type="password" class="form-control form-control-lg"
@bind-Value="_model.Password" placeholder="Create a password" />
<ValidationMessage For="() => _model.Password" class="text-danger" />
<div class="form-text">Password must be at least 6 characters.</div>
<div class="form-text">Password must be at least 12 characters with uppercase, lowercase, number, and symbol.</div>
</div>
<div class="mb-4">
@@ -153,7 +153,7 @@
public string Email { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
[System.ComponentModel.DataAnnotations.MinLength(6, ErrorMessage = "Password must be at least 6 characters")]
[System.ComponentModel.DataAnnotations.MinLength(12, ErrorMessage = "Password must be at least 12 characters")]
public string Password { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your password")]

View File

@@ -5,6 +5,7 @@
@inject ICVCheckService CVCheckService
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Check> Logger
<PageTitle>Upload CV - TrueCV</PageTitle>
@@ -145,6 +146,10 @@
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
// Magic bytes for file type validation
private static readonly byte[] PdfMagicBytes = [0x25, 0x50, 0x44, 0x46]; // %PDF
private static readonly byte[] DocxMagicBytes = [0x50, 0x4B, 0x03, 0x04]; // PK.. (ZIP signature)
private void HandleDragEnter()
{
_isDragging = true;
@@ -186,10 +191,15 @@
_errorMessage = null;
}
private CancellationTokenSource? _progressCts;
private async Task UploadFile()
{
if (_selectedFile is null) return;
_progressCts = new CancellationTokenSource();
Task? progressTask = null;
try
{
_isUploading = true;
@@ -207,35 +217,81 @@
}
// Simulate progress for better UX
var progressTask = SimulateProgress();
progressTask = SimulateProgress(_progressCts.Token);
await using var stream = _selectedFile.OpenReadStream(MaxFileSize);
using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
// Validate file content (magic bytes)
if (!await ValidateFileContentAsync(memoryStream, _selectedFile.Name))
{
_errorMessage = "Invalid file content. The file appears to be corrupted or not a valid PDF/DOCX.";
return;
}
var checkId = await CVCheckService.CreateCheckAsync(userId, memoryStream, _selectedFile.Name);
_uploadProgress = 100;
await InvokeAsync(StateHasChanged);
await Task.Delay(500); // Brief pause to show completion
NavigationManager.NavigateTo($"/report/{checkId}");
}
catch (Exception ex)
{
_errorMessage = $"An error occurred while uploading: {ex.Message}";
Logger.LogError(ex, "Error uploading CV");
_errorMessage = "An error occurred while uploading. Please try again.";
}
finally
{
_isUploading = false;
_progressCts?.Cancel();
if (progressTask is not null)
{
try { await progressTask; } catch (OperationCanceledException) { }
}
_progressCts?.Dispose();
_progressCts = null;
}
}
private async Task SimulateProgress()
private async Task SimulateProgress(CancellationToken cancellationToken)
{
while (_uploadProgress < 90 && _isUploading)
try
{
await Task.Delay(200);
_uploadProgress += 10;
StateHasChanged();
while (_uploadProgress < 90 && _isUploading && !cancellationToken.IsCancellationRequested)
{
await Task.Delay(200, cancellationToken);
_uploadProgress += 10;
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
// Expected when upload completes
}
}
private async Task<bool> ValidateFileContentAsync(MemoryStream stream, string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
var header = new byte[4];
stream.Position = 0;
var bytesRead = await stream.ReadAsync(header.AsMemory(0, 4));
stream.Position = 0;
if (bytesRead < 4)
return false;
return extension switch
{
".pdf" => header.AsSpan().StartsWith(PdfMagicBytes),
".docx" => header.AsSpan().StartsWith(DocxMagicBytes),
_ => false
};
}
private bool IsValidFileType(string fileName)

View File

@@ -5,6 +5,7 @@
@inject ICVCheckService CVCheckService
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Dashboard> Logger
<PageTitle>Dashboard - TrueCV</PageTitle>
@@ -255,7 +256,8 @@
}
catch (Exception ex)
{
_errorMessage = $"An error occurred while loading checks: {ex.Message}";
Logger.LogError(ex, "Error loading CV checks");
_errorMessage = "An error occurred while loading checks. Please try again.";
}
finally
{

View File

@@ -5,6 +5,7 @@
@inject ICVCheckService CVCheckService
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Report> Logger
<PageTitle>Verification Report - TrueCV</PageTitle>
@@ -509,7 +510,8 @@
}
catch (Exception ex)
{
_errorMessage = $"An error occurred: {ex.Message}";
Logger.LogError(ex, "Error loading report data");
_errorMessage = "An error occurred while loading the report. Please try again.";
}
finally
{