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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
<div class="cv-uploader @(_isDragOver ? "drag-over" : "")"
|
||||
@ondragenter="HandleDragEnter"
|
||||
@ondragenter:preventDefault
|
||||
@ondragleave="HandleDragLeave"
|
||||
@ondragleave:preventDefault
|
||||
@ondragover:preventDefault
|
||||
@ondrop="HandleDrop"
|
||||
@ondrop:preventDefault>
|
||||
|
||||
<InputFile OnChange="HandleFileSelected"
|
||||
accept=".pdf,.docx"
|
||||
class="cv-uploader-input"
|
||||
id="cv-file-input" />
|
||||
|
||||
<label for="cv-file-input" class="cv-uploader-label">
|
||||
@if (string.IsNullOrEmpty(_selectedFileName))
|
||||
{
|
||||
<div class="cv-uploader-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708z"/>
|
||||
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383m.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="cv-uploader-text">
|
||||
<span class="cv-uploader-title">Drag and drop your CV here</span>
|
||||
<span class="cv-uploader-subtitle">or click to browse</span>
|
||||
<span class="cv-uploader-hint">Accepts .pdf and .docx files (max 10MB)</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cv-uploader-icon text-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-file-earmark-check" viewBox="0 0 16 16">
|
||||
<path d="M10.854 7.854a.5.5 0 0 0-.708-.708L7.5 9.793 6.354 8.646a.5.5 0 1 0-.708.708l1.5 1.5a.5.5 0 0 0 .708 0z"/>
|
||||
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="cv-uploader-text">
|
||||
<span class="cv-uploader-title text-success">File selected</span>
|
||||
<span class="cv-uploader-filename">@_selectedFileName</span>
|
||||
<span class="cv-uploader-hint">Click or drag to replace</span>
|
||||
</div>
|
||||
}
|
||||
</label>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-3 mb-0" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle-fill me-2" viewBox="0 0 16 16">
|
||||
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
|
||||
</svg>
|
||||
@_errorMessage
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cv-uploader {
|
||||
position: relative;
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background-color: #f8f9fa;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.cv-uploader:hover,
|
||||
.cv-uploader.drag-over {
|
||||
border-color: #0d6efd;
|
||||
background-color: #e7f1ff;
|
||||
}
|
||||
|
||||
.cv-uploader.drag-over {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.cv-uploader-input {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cv-uploader-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cv-uploader-icon {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.cv-uploader.drag-over .cv-uploader-icon {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.cv-uploader-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.cv-uploader-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.cv-uploader-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.cv-uploader-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.cv-uploader-filename {
|
||||
font-size: 0.875rem;
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10MB
|
||||
private static readonly string[] AllowedExtensions = [".pdf", ".docx"];
|
||||
|
||||
private bool _isDragOver;
|
||||
private string? _selectedFileName;
|
||||
private string? _errorMessage;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<IBrowserFile> OnFileSelected { get; set; }
|
||||
|
||||
private void HandleDragEnter()
|
||||
{
|
||||
_isDragOver = true;
|
||||
}
|
||||
|
||||
private void HandleDragLeave()
|
||||
{
|
||||
_isDragOver = false;
|
||||
}
|
||||
|
||||
private void HandleDrop()
|
||||
{
|
||||
_isDragOver = false;
|
||||
}
|
||||
|
||||
private async Task HandleFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_errorMessage = null;
|
||||
_selectedFileName = null;
|
||||
|
||||
var file = e.File;
|
||||
if (file is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(file.Name).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
{
|
||||
_errorMessage = "Invalid file type. Please upload a .pdf or .docx file.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.Size > MaxFileSizeBytes)
|
||||
{
|
||||
_errorMessage = "File size exceeds 10MB limit. Please upload a smaller file.";
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedFileName = file.Name;
|
||||
await OnFileSelected.InvokeAsync(file);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using Microsoft.JSInterop
|
||||
@using TrueCV.Web
|
||||
@using TrueCV.Web.Components
|
||||
|
||||
@@ -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) =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user