mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-01 11:03:14 +00:00
Forgot Password answers every address the same way and emails a code only to an active account. Codes are stored as salted SHA-256 hashes, expire 15 minutes after issue, are replaced by a newer request, and are checked only against the email they were issued to. Five failed checks delete the code; attempts are reserved with one conditional UPDATE so concurrent guesses cannot exceed the budget. VerificationCode requires the email, and email and code are accepted in the JSON body so they stay out of URLs. The three anonymous endpoints are rate limited to 10 requests per 15 minutes per client IP. Forwarded headers are trusted only through loopback and private hops, since the API sits behind the EB load balancer and nginx. The migration adds hash, salt, expiry and attempt columns and deletes the old plaintext rows.
294 lines
13 KiB
C#
294 lines
13 KiB
C#
using Api.SeaHavenIndustries.Helper;
|
|
using Api.SeaHavenIndustries.HostedServices;
|
|
using Api.SeaHavenIndustries.Infrastructure;
|
|
using Api.SeaHavenIndustries.Middleware;
|
|
using Api.SeaHavenIndustries.Observability;
|
|
using Api.SeaHavenIndustries.Options;
|
|
using Data.SeaHavenIndustries;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.ResponseCompression;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using Microsoft.OpenApi.Models;
|
|
using Sentry.AspNetCore;
|
|
using Sentry.Extensibility;
|
|
using System.Text;
|
|
using SeaHaven.DataServices.DependencyInjection;
|
|
using SeaHaven.Services.DependencyInjection;
|
|
using SeaHaven.Services.Implementation;
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
var entryAssembly = System.Reflection.Assembly.GetEntryAssembly();
|
|
var release = SentryObservability.ResolveRelease(entryAssembly);
|
|
var commitSha = SentryObservability.ResolveCommitSha(entryAssembly);
|
|
|
|
builder.WebHost.UseSentry((SentryAspNetCoreOptions options) =>
|
|
{
|
|
options.Dsn = builder.Configuration["SENTRY_DSN"] ?? string.Empty;
|
|
options.Environment = builder.Configuration["SENTRY_ENVIRONMENT"]
|
|
?? builder.Environment.EnvironmentName.ToLowerInvariant();
|
|
options.Release = release;
|
|
options.DefaultTags.Add("service", SentryObservability.ServiceName);
|
|
options.DefaultTags.Add("app.commit", commitSha ?? SentryObservability.LocalDevelopmentRelease);
|
|
options.TracesSampleRate = 1.0;
|
|
options.SendDefaultPii = false;
|
|
options.MaxRequestBodySize = RequestSize.None;
|
|
options.SetBeforeSend(SentryTelemetryScrubber.Scrub);
|
|
options.SetBeforeSendTransaction(SentryTelemetryScrubber.Scrub);
|
|
});
|
|
|
|
ConfigurationManager configuration = builder.Configuration;
|
|
|
|
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
|
|
|
|
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
|
{
|
|
options.User.RequireUniqueEmail = false;
|
|
})
|
|
.AddEntityFrameworkStores<ApplicationDbContext>()
|
|
.AddDefaultTokenProviders();
|
|
|
|
builder.Services.AddControllers(options =>
|
|
{
|
|
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
|
|
options.Filters.Add<Api.SeaHavenIndustries.Filters.ArgumentExceptionFilter>();
|
|
}).AddJsonOptions(options =>
|
|
{
|
|
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
|
|
});
|
|
builder.Services.AddResponseCompression(opts =>
|
|
{
|
|
opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
|
|
new[] { "application/octet-stream" });
|
|
});
|
|
builder.Services.AddPasswordResetRateLimiting();
|
|
builder.Services.AddCors(option =>
|
|
option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
|
|
|
|
builder.Services.AddScoped<SendMessage>();
|
|
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
|
|
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IEmailSender, Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
builder.Services.AddScoped<IDocumentScanner, ClamAvDocumentScanner>();
|
|
builder.Services.AddHostedService<VendorDocumentScanWorker>();
|
|
builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
|
|
{
|
|
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();
|
|
var env = sp.GetRequiredService<IWebHostEnvironment>();
|
|
var csvPath = Path.Combine(env.WebRootPath, "zipcodes.csv");
|
|
if (File.Exists(csvPath)) zipService.Load(csvPath);
|
|
return zipService;
|
|
});
|
|
builder.Services.AddSingleton<SeaHaven.Services.Interfaces.IZipCodeDistance>(
|
|
serviceProvider => serviceProvider.GetRequiredService<Api.SeaHavenIndustries.Helper.ZipCodeDistance>());
|
|
|
|
builder.Services.AddDataServices();
|
|
builder.Services.AddBusinessServices(configuration);
|
|
builder.Services.AddScoped<SeaHaven.Services.Interfaces.ISyncExternalSource, Api.SeaHavenIndustries.Infrastructure.DynamoSyncExternalSource>();
|
|
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IFileStoragePort, Api.SeaHavenIndustries.Infrastructure.FileStorageAdapter>();
|
|
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IDispatchEmailPort, Api.SeaHavenIndustries.Infrastructure.DispatchEmailAdapter>();
|
|
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorTokenPort, Api.SeaHavenIndustries.Infrastructure.VendorTokenAdapter>();
|
|
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorDocumentStoragePort, Api.SeaHavenIndustries.Infrastructure.VendorDocumentStorageAdapter>();
|
|
builder.Services.AddSingleton<Amazon.SecretsManager.IAmazonSecretsManager>(_ =>
|
|
{
|
|
var region = builder.Configuration[$"{SeaHaven.Services.Configuration.WorkOrderWebhookOptions.SectionName}:Region"];
|
|
return string.IsNullOrWhiteSpace(region)
|
|
? new Amazon.SecretsManager.AmazonSecretsManagerClient()
|
|
: new Amazon.SecretsManager.AmazonSecretsManagerClient(Amazon.RegionEndpoint.GetBySystemName(region));
|
|
});
|
|
builder.Services.AddSingleton<
|
|
SeaHaven.Services.Interfaces.IWorkOrderWebhookSecretProvider,
|
|
Api.SeaHavenIndustries.Infrastructure.AwsWorkOrderWebhookSecretProvider>();
|
|
builder.Services.AddSingleton<
|
|
Api.SeaHavenIndustries.Infrastructure.IProcurementAwsCredentialsProvider,
|
|
Api.SeaHavenIndustries.Infrastructure.DefaultProcurementAwsCredentialsProvider>();
|
|
builder.Services.AddHttpClient<
|
|
SeaHaven.Services.Interfaces.IProcurementWorkOrderClient,
|
|
Api.SeaHavenIndustries.Infrastructure.ProcurementWorkOrderClient>()
|
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
|
{
|
|
AllowAutoRedirect = false
|
|
});
|
|
|
|
builder.Services.Configure<WorkOrderJobsOptions>(
|
|
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
|
|
builder.Services.AddHostedService<WorkOrderReconciliationHostedService>();
|
|
builder.Services.AddOptions<WorkOrderIngestOptions>()
|
|
.Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName))
|
|
.Validate(
|
|
o => !o.Enabled
|
|
|| (!string.IsNullOrWhiteSpace(o.ApiKey)
|
|
&& !o.ApiKey.Contains("${", StringComparison.Ordinal)
|
|
&& o.ApiKey.Length >= 32),
|
|
"WorkOrderIngest:ApiKey must be a real secret (env/user-secrets, length >= 32) when ingest is enabled.")
|
|
.ValidateOnStart();
|
|
builder.Services.Configure<SyncOptions>(
|
|
builder.Configuration.GetSection(SyncOptions.SectionName));
|
|
builder.Services.Configure<LegacyEndpointOptions>(
|
|
builder.Configuration.GetSection(LegacyEndpointOptions.SectionName));
|
|
builder.Services.AddSingleton<WorkOrderJobRunStateAccessor>();
|
|
builder.Services.AddSingleton<IWorkOrderJobRunStateAccessor>(
|
|
sp => sp.GetRequiredService<WorkOrderJobRunStateAccessor>());
|
|
builder.Services.AddSingleton(sp => sp.GetRequiredService<WorkOrderJobRunStateAccessor>().State);
|
|
builder.Services.AddScoped<Api.SeaHavenIndustries.Filters.IngestApiKeyFilter>();
|
|
builder.Services.AddHostedService<WorkOrderWeekRolledHostedService>();
|
|
builder.Services.AddHostedService<PastDueCacheHostedService>();
|
|
builder.Services.AddHostedService<UpliftLifecycleHostedService>();
|
|
builder.Services.AddOptions<SeaHaven.Services.Implementation.WorkOrderOpsHealthOptions>()
|
|
.Configure<Microsoft.Extensions.Options.IOptions<SyncOptions>,
|
|
Microsoft.Extensions.Options.IOptions<WorkOrderIngestOptions>,
|
|
Microsoft.Extensions.Options.IOptions<LegacyEndpointOptions>>(
|
|
(health, sync, ingest, legacy) =>
|
|
{
|
|
health.SyncEnabled = sync.Value.Enabled;
|
|
health.IngestEnabled = ingest.Value.Enabled;
|
|
health.LegacyDeprecationEnabled = legacy.Value.DeprecationEnabled;
|
|
health.LegacySunsetDate = legacy.Value.SunsetDate;
|
|
});
|
|
|
|
builder.Services.AddAuthentication(options =>
|
|
{
|
|
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
|
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
|
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
|
|
})
|
|
.AddJwtBearer(options =>
|
|
{
|
|
options.SaveToken = true;
|
|
options.RequireHttpsMetadata = false;
|
|
options.TokenValidationParameters = new TokenValidationParameters()
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidAudience = configuration["JWT:ValidAudience"],
|
|
ValidIssuer = configuration["JWT:ValidIssuer"],
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
|
|
configuration["JWT:Secret"]
|
|
?? throw new InvalidOperationException("JWT:Secret configuration is required")))
|
|
};
|
|
});
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddSwaggerGen(c =>
|
|
{
|
|
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Seahaven", Version = "v1" });
|
|
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme()
|
|
{
|
|
Name = "Authorization",
|
|
Type = SecuritySchemeType.ApiKey,
|
|
Scheme = "Bearer",
|
|
BearerFormat = "JWT",
|
|
In = ParameterLocation.Header,
|
|
Description = "JWT Authorization header using the Bearer scheme. \r\n\r\n Enter 'Bearer' [space] and then your token in the text input below.\r\n\r\nExample: \"Bearer 12345abcdef\"",
|
|
});
|
|
c.AddSecurityRequirement(new OpenApiSecurityRequirement
|
|
{
|
|
{
|
|
new OpenApiSecurityScheme
|
|
{
|
|
Reference = new OpenApiReference
|
|
{
|
|
Type = ReferenceType.SecurityScheme,
|
|
Id = "Bearer"
|
|
}
|
|
},
|
|
new string[] {}
|
|
}
|
|
});
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
// First, so every later middleware and the rate limiter see the real client address.
|
|
app.UseForwardedHeaders();
|
|
|
|
// Configure the HTTP request pipeline.
|
|
if (app.Environment.IsDevelopment() || app.Environment.IsProduction())
|
|
{
|
|
app.UseSwagger();
|
|
app.UseSwaggerUI(c =>
|
|
{
|
|
c.SwaggerEndpoint("/swagger/v1/swagger.json", "Sari");
|
|
c.RoutePrefix = string.Empty;
|
|
});
|
|
}
|
|
|
|
if (!app.Environment.IsDevelopment())
|
|
app.UseHttpsRedirection();
|
|
app.UseStaticFiles();
|
|
app.UseCors();
|
|
app.UseMiddleware<LegacyDeprecationMiddleware>();
|
|
app.UseRouting();
|
|
app.UseRateLimiter();
|
|
app.UseAuthentication();
|
|
app.UseMiddleware<SentryRequestMetadataMiddleware>();
|
|
app.UseAuthorization();
|
|
|
|
app.MapControllers();
|
|
|
|
using (var ownerScope = app.Services.CreateScope())
|
|
{
|
|
await ownerScope.ServiceProvider
|
|
.GetRequiredService<IAccountOwnerDesignationService>()
|
|
.EnsureConfiguredOwnerAsync(CancellationToken.None);
|
|
}
|
|
|
|
//if (app.Environment.IsDevelopment())
|
|
//{
|
|
// using var scope = app.Services.CreateScope();
|
|
// var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
|
// var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
|
|
|
// if (!await roleManager.RoleExistsAsync("Admin"))
|
|
// await roleManager.CreateAsync(new IdentityRole("Admin"));
|
|
// if (!await roleManager.RoleExistsAsync("Dispatcher"))
|
|
// await roleManager.CreateAsync(new IdentityRole("Dispatcher"));
|
|
// if (!await roleManager.RoleExistsAsync("Manager"))
|
|
// await roleManager.CreateAsync(new IdentityRole("Manager"));
|
|
|
|
// var adminEmail = "admin@seahavenind.com";
|
|
// if (await userManager.FindByNameAsync(adminEmail) == null)
|
|
// {
|
|
// var admin = new ApplicationUser
|
|
// {
|
|
// UserName = adminEmail,
|
|
// Email = adminEmail,
|
|
// FirstName = "Admin",
|
|
// LastName = "User",
|
|
// EmailConfirmed = true,
|
|
// UniqueName = "Active",
|
|
// CreatedDate = DateTime.Now
|
|
// };
|
|
// var result = await userManager.CreateAsync(admin, "Admin@123");
|
|
// if (result.Succeeded)
|
|
// await userManager.AddToRoleAsync(admin, "Admin");
|
|
// }
|
|
|
|
// var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
// if (!db.DropdownOptions.Any())
|
|
// {
|
|
// var trades = new[] { "Plumbing", "Electrical", "HVAC", "Fire/Life Safety", "General", "Roofing", "Janitorial", "Painting", "Locksmith", "Carpentry" };
|
|
// for (int i = 0; i < trades.Length; i++)
|
|
// db.DropdownOptions.Add(new DropdownOption { Category = "Trade", Value = trades[i], SortOrder = i });
|
|
|
|
// var subTrades = new (string trade, string sub)[] {
|
|
// ("Plumbing", "Drain Cleaning"), ("Plumbing", "Pipe Repair"), ("Plumbing", "Fixture Install"), ("Plumbing", "Backflow Testing"),
|
|
// ("Electrical", "Lighting"), ("Electrical", "Panel/Breaker"), ("Electrical", "Wiring"), ("Electrical", "Generator"),
|
|
// ("HVAC", "AC Repair"), ("HVAC", "Heating"), ("HVAC", "Ductwork"), ("HVAC", "Controls/BMS"),
|
|
// ("Fire/Life Safety", "Sprinkler"), ("Fire/Life Safety", "Fire Alarm"), ("Fire/Life Safety", "Extinguisher"), ("Fire/Life Safety", "Fire Pump"),
|
|
// ("General", "Doors"), ("General", "Flooring"), ("General", "Drywall"), ("General", "Concrete"),
|
|
// };
|
|
// for (int i = 0; i < subTrades.Length; i++)
|
|
// db.DropdownOptions.Add(new DropdownOption { Category = "SubTrade", Value = subTrades[i].sub, ParentValue = subTrades[i].trade, SortOrder = i });
|
|
|
|
// var problems = new[] { "Leak", "No Power", "Not Cooling", "Not Heating", "Clogged", "Broken/Damaged", "Alarm Fault", "Preventive Maintenance", "Inspection", "Installation", "Other" };
|
|
// for (int i = 0; i < problems.Length; i++)
|
|
// db.DropdownOptions.Add(new DropdownOption { Category = "Problem", Value = problems[i], SortOrder = i });
|
|
|
|
// await db.SaveChangesAsync();
|
|
// }
|
|
//}
|
|
|
|
app.Run();
|