DeutschLernen/GermanApp/Program.cs
Lasse Rune Hansen 42778ec34b fix(backend/auth): Fix authentication bugs
- Fix case-insensitive email lookups in AuthService (RegisterAsync, LoginAsync, CreateAdminUserAsync)
- Fix User.Create factory method to explicitly set Role property (C# object initializer behavior)
- Assign Admin role to seeded admin user in SeedDataExtension
- Add [AllowAnonymous] to Register and Login endpoints in AuthController (defensive programming)
- Increase JWT ClockSkew to 5 minutes for clock difference tolerance

These fixes resolve issues where:
- Login fails with 401 due to case-sensitive email comparison
- User role is null instead of 'User' or 'Admin'
- JWT token validation too strict on clock synchronization

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-14 16:40:01 +02:00

419 lines
15 KiB
C#

using FluentValidation;
using FluentValidation.AspNetCore;
using GermanApp.Application.DTOs;
using GermanApp.Application.Services;
using GermanApp.Application.Interfaces;
using GermanApp.Application.UseCases.Commands;
using GermanApp.Domain.Entities;
using GermanApp.Domain.Interfaces;
using GermanApp.Infrastructure.Data.DbContext;
using GermanApp.Infrastructure.Data.Repositories;
using GermanApp.Infrastructure.Data.SeedData;
using GermanApp.Infrastructure.Services;
using GermanApp.Infrastructure.Configuration;
using GermanApp.Presentation.Controllers;
using GermanApp.Presentation.Validators;
using GermanApp.Presentation.Endpoints;
using GermanApp.Shared.Middleware;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Serilog;
using System.IO;
using System.Text;
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
// Add Serilog to the builder
builder.Host.UseSerilog((ctx, lc) => lc
.MinimumLevel.Debug()
.WriteTo.Console()
.ReadFrom.Configuration(ctx.Configuration));
// ============================================
// PRESENTATION LAYER - API Configuration
// ============================================
// Add services to the container.
builder.Services.AddOpenApi();
// Add Health Checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>()
.AddCheck<AiServicesHealthCheck>("ai_services");
// Add Password Hasher for custom User entity
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
// Configure JWT Authentication
var jwtKey = builder.Configuration["Jwt:Key"] ?? "super-secret-key-at-least-32-characters";
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "DeutschLernen";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "DeutschLernen";
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes of clock difference
};
});
// Add Authorization
builder.Services.AddAuthorization();
// Register AuthService
builder.Services.AddScoped<IAuthService, AuthService>();
// Configure CORS
builder.Services.AddCors(options =>
{
// AllowAll policy - for production without credentials
// Note: Cannot use AllowAnyOrigin() with AllowCredentials()
options.AddPolicy("AllowAll", builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
// Note: No AllowCredentials() - cannot combine with AllowAnyOrigin()
});
// Development policy - for Docker and local development with credentials
options.AddPolicy("Development", builder =>
{
builder.WithOrigins("http://localhost:5173", "http://localhost:5174", "http://localhost:5175", "http://localhost:3000")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
// Docker policy - for when frontend is served from Docker nginx
options.AddPolicy("Docker", builder =>
{
builder.WithOrigins("http://localhost:3000")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
builder.Services.AddControllers();
// ============================================
// PRESENTATION LAYER - Validation
// ============================================
// Add FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<CreateLevelValidator>();
builder.Services.AddFluentValidationAutoValidation();
// Add services for Minimal APIs
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// ============================================
// INFRASTRUCTURE LAYER - Database & External Services
// ============================================
// Add DbContext with PostgreSQL
builder.Services.AddDbContext<AppDbContext>(options =>
{
// Using PostgreSQL for production and development
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")
?? "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres");
// Enable sensitive data logging in development
if (builder.Environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
}
});
// Register repositories (Infrastructure implementations of Domain interfaces)
builder.Services.AddScoped<IRepository<User, int>, UserRepository>();
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
builder.Services.AddScoped<IQuizRepository, QuizRepository>();
builder.Services.AddScoped<IQuizQuestionRepository, QuizQuestionRepository>();
builder.Services.AddScoped<IQuizOptionRepository, QuizOptionRepository>();
builder.Services.AddScoped<IStoryRepository, StoryRepository>();
builder.Services.AddScoped<IStoryProgressRepository, StoryProgressRepository>();
// ============================================
// INFRASTRUCTURE LAYER - AI Services
// ============================================
// Add Memory Cache for caching (used by AI services)
builder.Services.AddMemoryCache();
// Add Mistral API configuration
builder.Services.Configure<MistralConfig>(builder.Configuration.GetSection("Mistral"));
// Add HttpClient for Mistral API
builder.Services.AddHttpClient("MistralClient");
// APPLICATION LAYER - Use Cases & Services
// ============================================
// Register Mistral Connector
builder.Services.AddScoped<IMistralConnector>(provider =>
{
var httpClient = provider.GetRequiredService<IHttpClientFactory>().CreateClient("MistralClient");
var config = provider.GetRequiredService<IOptions<MistralConfig>>().Value;
var logger = provider.GetRequiredService<ILogger<MistralConnector>>();
var cache = provider.GetRequiredService<IMemoryCache>();
return new MistralConnector(httpClient, config, logger, cache);
});
// Add AI service configurations
builder.Services.Configure<VoskConfig>(builder.Configuration.GetSection("Vosk"));
builder.Services.Configure<CoquiConfig>(builder.Configuration.GetSection("Coqui"));
// Validate AI service configurations
try
{
ValidateAiConfigurations(builder.Configuration);
}
catch (Exception)
{
// Skip validation during EF migrations and design-time when configs may not be fully set
}
// Register AI services (Infrastructure implementations of Domain interfaces)
builder.Services.AddScoped<IMistralService, MistralService>();
builder.Services.AddScoped<IVoskService, VoskService>();
builder.Services.AddScoped<ITtsService, TtsService>();
// ============================================
// APPLICATION LAYER - Use Cases & Services
// ========================================================================================
// APPLICATION LAYER - Use Cases & Services
// ============================================
// Register command handlers
// Register application services
builder.Services.AddScoped<LevelService>();
builder.Services.AddScoped<LessonService>();
builder.Services.AddScoped<ProgressService>();
builder.Services.AddScoped<QuizService>();
builder.Services.AddScoped<QuizQuestionService>();
builder.Services.AddScoped<LessonUnlockService>();
builder.Services.AddScoped<LevelCompletionCalculator>();
// Register AI higher-level services
builder.Services.AddScoped<StoryGenerationService>();
builder.Services.AddScoped<WritingFeedbackService>();
builder.Services.AddScoped<SpeechExerciseService>();
builder.Services.AddScoped<AudioGenerationService>();
builder.Services.AddScoped<AiFallbackService>();
// Register Story services
builder.Services.AddScoped<StoryService>();
builder.Services.AddScoped<StoryUnlockService>();
// Register Admin services
builder.Services.AddScoped<IAdminService, AdminService>();
builder.Services.AddScoped<IUserReportService, UserReportService>();
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
// Note: QuizQuestionService requires IQuizRepository, ILessonRepository, and ProgressService which are registered above
// Register Quiz command handlers
builder.Services.AddScoped<ICommandHandler<CreateQuizCommand, QuizDto>, CreateQuizCommandHandler>();
builder.Services.AddScoped<ICommandHandler<UpdateQuizCommand, QuizDto?>, UpdateQuizCommandHandler>();
builder.Services.AddScoped<ICommandHandler<DeleteQuizCommand, bool>, DeleteQuizCommandHandler>();
builder.Services.AddScoped<ICommandHandler<GetQuizWithQuestionsCommand, QuizWithQuestionsDto?>, GetQuizWithQuestionsCommandHandler>();
var app = builder.Build();
// Create static file directories
bool isEfDesignTime = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true";
if (!isEfDesignTime)
{
try
{
Directory.CreateDirectory(Path.Combine("wwwroot", "audio", "story"));
}
catch
{
// Skip during migrations
}
}
// Configure the HTTP request pipeline.
// Use exception middleware first (to catch all exceptions)
app.UseExceptionMiddleware();
// Use CORS - must be early in the pipeline, before UseAuthorization
// In Docker, use "Docker" policy to allow credentials from localhost:3000
// In development without Docker, use "Development" policy
app.UseCors(app.Environment.IsDevelopment() ? "Development" : "Docker");
// Serve static files (audio, etc.)
app.UseStaticFiles();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI();
}
// Use Authentication & Authorization
app.UseAuthentication();
app.UseAuthorization();
// Use Health Checks
app.MapHealthChecks("/health");
// Map Auth endpoints
app.MapControllerRoute(
name: "api",
pattern: "api/{controller}/{action}/{id?}" );
// Map Clean Architecture endpoints
app.MapLessonsEndpoints();
// Seed database with initial data
// Run migrations and seed in Development, or just migrations in Production/Docker
// In Docker, we can control seeding via environment variable
var shouldSeed = app.Environment.IsDevelopment() ||
(app.Configuration.GetValue<bool>("SeedDatabase"));
if (shouldSeed)
{
await app.SeedDatabaseAsync();
}
else
{
// In Production/Docker, ensure migrations are applied with retry logic
await ApplyMigrationsWithRetry(app, maxRetries: 10, delaySeconds: 5);
}
// Keep original WeatherForecast endpoint for reference
app.MapGet("/weatherforecast", () =>
{
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast");
app.Run();
// Helper method to validate AI service configurations
static void ValidateAiConfigurations(IConfiguration configuration)
{
// Skip validation during EF migrations
if (Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true")
{
return;
}
// Validate Mistral configuration (only if API key is set)
var mistralConfig = configuration.GetSection("Mistral").Get<MistralConfig>() ?? new MistralConfig();
if (!string.IsNullOrWhiteSpace(mistralConfig.ApiKey))
{
mistralConfig.Validate();
}
// Validate Vosk configuration (only if model path is set)
var voskConfig = configuration.GetSection("Vosk").Get<VoskConfig>() ?? new VoskConfig();
if (!string.IsNullOrWhiteSpace(voskConfig.ModelPath))
{
voskConfig.Validate();
}
// Validate Coqui configuration (only if Python path is set)
var coquiConfig = configuration.GetSection("Coqui").Get<CoquiConfig>() ?? new CoquiConfig();
if (!string.IsNullOrWhiteSpace(coquiConfig.PythonPath))
{
coquiConfig.Validate();
}
Log.Information("All AI service configurations validated successfully");
}
// Helper method to apply migrations with retry logic for Docker
static async Task ApplyMigrationsWithRetry(WebApplication app, int maxRetries, int delaySeconds)
{
int retryCount = 0;
while (retryCount < maxRetries)
{
try
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
dbContext.Database.Migrate();
Log.Information("Database migrations applied in Production environment");
return;
}
catch (Exception ex)
{
retryCount++;
Log.Warning(ex, "Failed to apply database migrations (attempt {Attempt}/{MaxAttempts}). Retrying in {DelaySeconds} seconds...",
retryCount, maxRetries, delaySeconds);
if (retryCount >= maxRetries)
{
Log.Fatal(ex, "Failed to apply database migrations after {MaxAttempts} attempts", maxRetries);
throw;
}
await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
}
}
}
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
// Existing record for reference
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}