diff --git a/backend/Talmidon.Api/Program.cs b/backend/Talmidon.Api/Program.cs index 5ebd4a5..1a7702b 100644 --- a/backend/Talmidon.Api/Program.cs +++ b/backend/Talmidon.Api/Program.cs @@ -1,30 +1,8 @@ -using System.Text; -using System.Threading.RateLimiting; using Hangfire; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.RateLimiting; -using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; using Talmidon.Api.Diagnostics; -using Talmidon.Api.Multitenancy; +using Talmidon.Api.Startup; using Talmidon.Infrastructure; -using Talmidon.Infrastructure.Auth; -using Talmidon.Infrastructure.BackgroundJobs; -using Talmidon.Infrastructure.Data; -using Talmidon.Infrastructure.Identity; -using Talmidon.Api.Services; -using Talmidon.Api.Services.Auth; -using Talmidon.Api.Services.Lessons; -using Talmidon.Api.Services.Messages; -using Talmidon.Api.Services.Parents; -using Talmidon.Api.Services.Payments; -using Talmidon.Api.Services.Public; -using Talmidon.Api.Services.Students; -using Talmidon.Api.Services.Teachers; -using Talmidon.Infrastructure.Multitenancy; var builder = WebApplication.CreateBuilder(args); @@ -34,107 +12,10 @@ // שכבת התשתית: DbContext (PostgreSQL), Identity, טוקנים, מיילים builder.Services.AddInfrastructure(builder.Configuration); -// ספק דייר אמיתי מתוך טוקן ה-JWT (מחליף את NullCurrentTenant) -builder.Services.AddHttpContextAccessor(); -builder.Services.AddScoped(); - -// הודעות למשפחה של תלמיד/ה — משותף לבקרי השיעורים והסדרות -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// חשבונות: הרשמה, מיילים, אסימונים -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// היומן: קריאות, פעולות המורה, ובקשות מהמשפחה -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// תשלומים: קריאות, ורישום/ביטול תשלום -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// תלמידים -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// הספרייה הציבורית והפניות ממנה -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// ניהול: חשבונות מורות והצעות תחומי לימוד -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// הפרופיל ושעות הזמינות של המורה -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// דוחות -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// שיחות בין המורה למשפחה -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// אימות JWT -var jwt = builder.Configuration.GetSection("Jwt"); -var secret = jwt["SecretKey"] - ?? throw new InvalidOperationException("Jwt:SecretKey is not configured."); -if (Encoding.UTF8.GetByteCount(secret) < 32) - throw new InvalidOperationException("Jwt:SecretKey must be at least 32 bytes (256 bits) for HMAC-SHA256."); - -var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)); - -builder.Services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = jwt["Issuer"], - ValidateAudience = true, - ValidAudience = jwt["Audience"], - ValidateIssuerSigningKey = true, - IssuerSigningKey = signingKey, - ValidateLifetime = true, - ClockSkew = TimeSpan.FromMinutes(1) - }; - }); - -// כל נקודת קצה דורשת אימות כברירת מחדל; נקודות ציבוריות מסומנות [AllowAnonymous] -builder.Services.AddAuthorization(options => - options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()); - -// הגבלת קצב לנקודות האימות (לפי כתובת IP) — ניתנת לכיוונון בלי קומפילציה מחדש; -// גם מאפשרת להרחיב את המכסה בבדיקות אינטגרציה, שבהן כל הבקשות חולקות "כתובת" מזוהה אחת. -builder.Services.AddRateLimiter(options => -{ - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - var permitLimit = builder.Configuration.GetValue("RateLimiting:Auth:PermitLimit", 10); - var windowMinutes = builder.Configuration.GetValue("RateLimiting:Auth:WindowMinutes", 1); - options.AddPolicy("auth", httpContext => - RateLimitPartition.GetFixedWindowLimiter( - partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = permitLimit, - Window = TimeSpan.FromMinutes(windowMinutes), - QueueLimit = 0 - })); -}); +// שירותי ה-API, אימות JWT והגבלת קצב לנקודות האימות +builder.Services.AddApiServices(); +builder.Services.AddJwtAuthentication(builder.Configuration); +builder.Services.AddAuthRateLimiting(builder.Configuration); // CORS לאפליקציית ה-Angular const string CorsPolicy = "TalmidonClient"; @@ -175,9 +56,7 @@ // במקום להסיק אותה משגיאת CORS בדפדפן. app.Logger.LogInformation("CORS allowed origins: {Origins}", string.Join(", ", allowedOrigins)); -await MigrateDatabaseAsync(app); -await SeedRolesAsync(app); -await SeedAdminUserAsync(app); +await app.InitializeDatabaseAsync(); if (app.Environment.IsDevelopment()) { @@ -219,95 +98,8 @@ app.UseAuthentication(); app.UseAuthorization(); -// המשימות רשומות בכל סביבה: השיעורים החוזרים והתזכורות הם חלק מהמוצר, ובלעדיהן -// המורה צריכה ליצור כל שיעור ביד. -// לוח הבקרה של Hangfire נשאר לפיתוח בלבד; הוא מאמת דרך Cookie ולא מכיר את ה-JWT. -// -// ה-API הסטטי RecurringJob שלמטה קורא מ-JobStorage.Current, ובלעדיו העלייה נופלת -// ב-"Current JobStorage instance has not been initialized yet". השרת עצמו כבר רשום -// ב-DI דרך AddHangfireServer ורץ כשירות רקע, ולכן נותר רק להצביע על אותו אחסון — -// במקום UseHangfireServer, שהוא מיושן ומרים שרת שני. -JobStorage.Current = app.Services.GetRequiredService(); - -RecurringJob.AddOrUpdate( - "monthly-payment-reminders", - job => job.RunForAllTenantsAsync(), - Cron.Monthly(), - new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); - -RecurringJob.AddOrUpdate( - "lesson-series-generation", - job => job.RunForAllTenantsAsync(), - Cron.Daily(), - new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); - -RecurringJob.AddOrUpdate( - "error-log-cleanup", - job => job.RunAsync(), - Cron.Daily(), - new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); - -RecurringJob.AddOrUpdate( - "lesson-reminders", - job => job.RunForAllTenantsAsync(), - Cron.Hourly(), - new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); +app.ScheduleRecurringJobs(); app.MapControllers(); app.Run(); - -/// מחילה מיגרציות ממתינות באתחול — כדי שפריסה (deploy) תהיה "git pull + docker compose up" בלי צעד ידני נפרד. -static async Task MigrateDatabaseAsync(WebApplication app) -{ - using var scope = app.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await db.Database.MigrateAsync(); -} - -// זריעת התפקידים (Teacher/Parent/Student/Admin) אם חסרים -static async Task SeedRolesAsync(WebApplication app) -{ - using var scope = app.Services.CreateScope(); - var roleManager = scope.ServiceProvider.GetRequiredService>(); - foreach (var role in Roles.All) - { - if (!await roleManager.RoleExistsAsync(role)) - await roleManager.CreateAsync(new IdentityRole(role)); - } -} - -/// -/// זורעת משתמש-על יחיד לתחזוקת הפלטפורמה, אם מוגדר ב-Admin:Email/Admin:Password. בניגוד למורה/הורה/תלמיד, -/// אין הרשמה עצמית או הזמנה למנהל — זו הדרך היחידה שבה חשבון כזה נוצר, ורק אם הוגדר במפורש (כלומר -/// בפרודקשן חובה להגדיר את שני הערכים ב-secrets/סביבת ההרצה, אחרת לא ייווצר אף חשבון מנהל). -/// -static async Task SeedAdminUserAsync(WebApplication app) -{ - var email = app.Configuration["Admin:Email"] - ?? Environment.GetEnvironmentVariable("ADMIN_EMAIL"); - var password = app.Configuration["Admin:Password"] - ?? Environment.GetEnvironmentVariable("ADMIN_PASSWORD"); - if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password)) - return; - - using var scope = app.Services.CreateScope(); - var userManager = scope.ServiceProvider.GetRequiredService>(); - if (await userManager.FindByEmailAsync(email) is not null) - return; - - var admin = new ApplicationUser { UserName = email, Email = email, DisplayName = "מנהל מערכת", EmailConfirmed = true }; - var createResult = await userManager.CreateAsync(admin, password); - if (!createResult.Succeeded) - { - var errors = string.Join("; ", createResult.Errors.Select(error => error.Description)); - throw new InvalidOperationException($"Admin user could not be created: {errors}"); - } - - var roleResult = await userManager.AddToRoleAsync(admin, Roles.Admin); - if (!roleResult.Succeeded) - { - var errors = string.Join("; ", roleResult.Errors.Select(error => error.Description)); - throw new InvalidOperationException($"Admin role could not be assigned: {errors}"); - } -} diff --git a/backend/Talmidon.Api/Startup/ApiServices.cs b/backend/Talmidon.Api/Startup/ApiServices.cs new file mode 100644 index 0000000..3be040d --- /dev/null +++ b/backend/Talmidon.Api/Startup/ApiServices.cs @@ -0,0 +1,76 @@ +using Talmidon.Api.Multitenancy; +using Talmidon.Api.Services; +using Talmidon.Api.Services.Auth; +using Talmidon.Api.Services.Lessons; +using Talmidon.Api.Services.Messages; +using Talmidon.Api.Services.Parents; +using Talmidon.Api.Services.Payments; +using Talmidon.Api.Services.Public; +using Talmidon.Api.Services.Students; +using Talmidon.Api.Services.Teachers; +using Talmidon.Infrastructure.Multitenancy; + +namespace Talmidon.Api.Startup; + +public static class ApiServices +{ + /// שירותי שכבת ה-API: הדייר הנוכחי והשירותים שהבקרים נשענים עליהם. + public static IServiceCollection AddApiServices(this IServiceCollection services) + { + // ספק דייר אמיתי מתוך טוקן ה-JWT (מחליף את NullCurrentTenant) + services.AddHttpContextAccessor(); + services.AddScoped(); + + // הודעות למשפחה של תלמיד/ה — משותף לבקרי השיעורים והסדרות + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // חשבונות: הרשמה, מיילים, אסימונים + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // היומן: קריאות, פעולות המורה, ובקשות מהמשפחה + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // תשלומים: קריאות, ורישום/ביטול תשלום + services.AddScoped(); + services.AddScoped(); + + // תלמידים + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // הספרייה הציבורית והפניות ממנה + services.AddScoped(); + services.AddScoped(); + + // ניהול: חשבונות מורות והצעות תחומי לימוד + services.AddScoped(); + services.AddScoped(); + + // הפרופיל ושעות הזמינות של המורה + services.AddScoped(); + services.AddScoped(); + + // דוחות + services.AddScoped(); + services.AddScoped(); + + // שיחות בין המורה למשפחה + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/backend/Talmidon.Api/Startup/AuthSetup.cs b/backend/Talmidon.Api/Startup/AuthSetup.cs new file mode 100644 index 0000000..83908b7 --- /dev/null +++ b/backend/Talmidon.Api/Startup/AuthSetup.cs @@ -0,0 +1,68 @@ +using System.Text; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.IdentityModel.Tokens; + +namespace Talmidon.Api.Startup; + +public static class AuthSetup +{ + /// אימות JWT, וכל נקודת קצה דורשת אימות כברירת מחדל; נקודות ציבוריות מסומנות [AllowAnonymous]. + public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration) + { + var jwt = configuration.GetSection("Jwt"); + var secret = jwt["SecretKey"] + ?? throw new InvalidOperationException("Jwt:SecretKey is not configured."); + if (Encoding.UTF8.GetByteCount(secret) < 32) + throw new InvalidOperationException("Jwt:SecretKey must be at least 32 bytes (256 bits) for HMAC-SHA256."); + + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwt["Issuer"], + ValidateAudience = true, + ValidAudience = jwt["Audience"], + ValidateIssuerSigningKey = true, + IssuerSigningKey = signingKey, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1) + }; + }); + + services.AddAuthorization(options => + options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()); + + return services; + } + + /// + /// הגבלת קצב לנקודות האימות (לפי כתובת IP) — ניתנת לכיוונון בלי קומפילציה מחדש; + /// גם מאפשרת להרחיב את המכסה בבדיקות אינטגרציה, שבהן כל הבקשות חולקות "כתובת" מזוהה אחת. + /// + public static IServiceCollection AddAuthRateLimiting(this IServiceCollection services, IConfiguration configuration) + { + return services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + var permitLimit = configuration.GetValue("RateLimiting:Auth:PermitLimit", 10); + var windowMinutes = configuration.GetValue("RateLimiting:Auth:WindowMinutes", 1); + options.AddPolicy("auth", httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = permitLimit, + Window = TimeSpan.FromMinutes(windowMinutes), + QueueLimit = 0 + })); + }); + } +} diff --git a/backend/Talmidon.Api/Startup/DatabaseInitialization.cs b/backend/Talmidon.Api/Startup/DatabaseInitialization.cs new file mode 100644 index 0000000..e5be7a6 --- /dev/null +++ b/backend/Talmidon.Api/Startup/DatabaseInitialization.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Talmidon.Infrastructure.Auth; +using Talmidon.Infrastructure.Data; +using Talmidon.Infrastructure.Identity; + +namespace Talmidon.Api.Startup; + +public static class DatabaseInitialization +{ + /// מיגרציות, תפקידים ומשתמש-העל — מה שצריך להיות במסד לפני שהאפליקציה מקבלת בקשות. + public static async Task InitializeDatabaseAsync(this WebApplication app) + { + await MigrateDatabaseAsync(app); + await SeedRolesAsync(app); + await SeedAdminUserAsync(app); + } + + /// מחילה מיגרציות ממתינות באתחול — כדי שפריסה (deploy) תהיה "git pull + docker compose up" בלי צעד ידני נפרד. + private static async Task MigrateDatabaseAsync(WebApplication app) + { + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + } + + // זריעת התפקידים (Teacher/Parent/Student/Admin) אם חסרים + private static async Task SeedRolesAsync(WebApplication app) + { + using var scope = app.Services.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + foreach (var role in Roles.All) + { + if (!await roleManager.RoleExistsAsync(role)) + await roleManager.CreateAsync(new IdentityRole(role)); + } + } + + /// + /// זורעת משתמש-על יחיד לתחזוקת הפלטפורמה, אם מוגדר ב-Admin:Email/Admin:Password. בניגוד למורה/הורה/תלמיד, + /// אין הרשמה עצמית או הזמנה למנהל — זו הדרך היחידה שבה חשבון כזה נוצר, ורק אם הוגדר במפורש (כלומר + /// בפרודקשן חובה להגדיר את שני הערכים ב-secrets/סביבת ההרצה, אחרת לא ייווצר אף חשבון מנהל). + /// + private static async Task SeedAdminUserAsync(WebApplication app) + { + var email = app.Configuration["Admin:Email"] + ?? Environment.GetEnvironmentVariable("ADMIN_EMAIL"); + var password = app.Configuration["Admin:Password"] + ?? Environment.GetEnvironmentVariable("ADMIN_PASSWORD"); + if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password)) + return; + + using var scope = app.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + if (await userManager.FindByEmailAsync(email) is not null) + return; + + var admin = new ApplicationUser { UserName = email, Email = email, DisplayName = "מנהל מערכת", EmailConfirmed = true }; + var createResult = await userManager.CreateAsync(admin, password); + if (!createResult.Succeeded) + { + var errors = string.Join("; ", createResult.Errors.Select(error => error.Description)); + throw new InvalidOperationException($"Admin user could not be created: {errors}"); + } + + var roleResult = await userManager.AddToRoleAsync(admin, Roles.Admin); + if (!roleResult.Succeeded) + { + var errors = string.Join("; ", roleResult.Errors.Select(error => error.Description)); + throw new InvalidOperationException($"Admin role could not be assigned: {errors}"); + } + } +} diff --git a/backend/Talmidon.Api/Startup/RecurringJobs.cs b/backend/Talmidon.Api/Startup/RecurringJobs.cs new file mode 100644 index 0000000..57f1c2b --- /dev/null +++ b/backend/Talmidon.Api/Startup/RecurringJobs.cs @@ -0,0 +1,45 @@ +using Hangfire; +using Talmidon.Infrastructure.BackgroundJobs; + +namespace Talmidon.Api.Startup; + +public static class RecurringJobs +{ + /// + /// המשימות רשומות בכל סביבה: השיעורים החוזרים והתזכורות הם חלק מהמוצר, ובלעדיהן + /// המורה צריכה ליצור כל שיעור ביד. + /// לוח הבקרה של Hangfire נשאר לפיתוח בלבד; הוא מאמת דרך Cookie ולא מכיר את ה-JWT. + /// + public static void ScheduleRecurringJobs(this WebApplication app) + { + // ה-API הסטטי RecurringJob שלמטה קורא מ-JobStorage.Current, ובלעדיו העלייה נופלת + // ב-"Current JobStorage instance has not been initialized yet". השרת עצמו כבר רשום + // ב-DI דרך AddHangfireServer ורץ כשירות רקע, ולכן נותר רק להצביע על אותו אחסון — + // במקום UseHangfireServer, שהוא מיושן ומרים שרת שני. + JobStorage.Current = app.Services.GetRequiredService(); + + RecurringJob.AddOrUpdate( + "monthly-payment-reminders", + job => job.RunForAllTenantsAsync(), + Cron.Monthly(), + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + + RecurringJob.AddOrUpdate( + "lesson-series-generation", + job => job.RunForAllTenantsAsync(), + Cron.Daily(), + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + + RecurringJob.AddOrUpdate( + "error-log-cleanup", + job => job.RunAsync(), + Cron.Daily(), + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + + RecurringJob.AddOrUpdate( + "lesson-reminders", + job => job.RunForAllTenantsAsync(), + Cron.Hourly(), + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + } +}