Files
step3/api/QuestionsApi/Program.cs
T

248 lines
10 KiB
C#

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using QuestionsApi.Services;
using MongoDB.Bson;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Threading.RateLimiting;
if (args.FirstOrDefault() == "promote-content")
{
Environment.ExitCode = await ContentPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
return;
}
if (args.FirstOrDefault() == "promote-questions")
{
Environment.ExitCode = await QuestionPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
return;
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.ForwardLimit = 2;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
builder.Services.AddSingleton<MongoDatabaseProvider>();
builder.Services.AddSingleton<QuestionStore>();
builder.Services.AddSingleton<QuestionAttemptStore>();
builder.Services.AddSingleton<AnswerStatisticsStore>();
builder.Services.AddSingleton<RatingStore>();
builder.Services.AddSingleton<QuestionMediaStore>();
builder.Services.AddSingleton<QuestionReportStore>();
builder.Services.AddSingleton<ExamSimulationStore>();
builder.Services.AddSingleton<CcsStore>();
builder.Services.AddSingleton<CcsRatingStore>();
builder.Services.AddSingleton<CcsCaseReportStore>();
builder.Services.AddSingleton<AppFeedbackStore>();
builder.Services.AddSingleton<UserNotepadStore>();
builder.Services.AddSingleton<QuestionBookmarkStore>();
builder.Services.AddSingleton<ReviewQueueStore>();
builder.Services.AddSingleton<DashboardStore>();
builder.Services.AddSingleton<UserProfileStore>();
builder.Services.AddSingleton<AuthUserStore>();
builder.Services.AddSingleton<TokenBlacklistStore>();
builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<LegalPolicy>();
builder.Services.AddSingleton<SteamAuthTransactionStore>();
builder.Services.AddSingleton<SteamOpenIdService>();
builder.Services.AddSingleton<ExternalIdentityStore>();
builder.Services.AddSingleton<ExternalAuthTransactionStore>();
builder.Services.AddSingleton<IExternalAuthProvider, SteamExternalAuthProvider>();
builder.Services.AddSingleton<IExternalAuthProvider, GoogleExternalAuthProvider>();
builder.Services.AddSingleton<PasswordResetStore>();
builder.Services.AddSingleton<PasswordResetCodeService>();
builder.Services.AddSingleton<IPasswordResetEmailSender, SmtpPasswordResetEmailSender>();
builder.Services.AddSingleton<AuthDataInitializer>();
builder.Services.AddSingleton<DonationStore>();
builder.Services.AddSingleton<IStripeDonationGateway, StripeDonationGateway>();
builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
builder.Services.AddHttpClient();
builder.Services.AddRateLimiter(options =>
{
static RateLimitPartition<string> Fixed(HttpContext context, int permits, TimeSpan window) =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = permits,
Window = window,
QueueLimit = 0,
AutoReplenishment = true
});
options.AddPolicy("auth-register", context => Fixed(context, 5, TimeSpan.FromMinutes(15)));
options.AddPolicy("auth-login", context => Fixed(context, 20, TimeSpan.FromMinutes(5)));
options.AddPolicy("auth-refresh", context => Fixed(context, 60, TimeSpan.FromMinutes(5)));
options.AddPolicy("password-reset-request", context => Fixed(context, 5, TimeSpan.FromMinutes(15)));
options.AddPolicy("password-reset-confirm", context => Fixed(context, 10, TimeSpan.FromMinutes(15)));
options.AddPolicy("steam-start", context => Fixed(context, 10, TimeSpan.FromMinutes(1)));
options.AddPolicy("steam-poll", context => Fixed(context, 120, TimeSpan.FromMinutes(1)));
options.AddPolicy("steam-complete", context => Fixed(context, 20, TimeSpan.FromMinutes(1)));
options.AddPolicy("external-start", context => Fixed(context, 10, TimeSpan.FromMinutes(1)));
options.AddPolicy("external-poll", context => Fixed(context, 120, TimeSpan.FromMinutes(1)));
options.AddPolicy("external-complete", context => Fixed(context, 20, TimeSpan.FromMinutes(1)));
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Questions API", Version = "v1" });
c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "Paste your access token here (no 'Bearer ' prefix needed)."
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{ Type = ReferenceType.SecurityScheme, Id = "bearerAuth" }
},
Array.Empty<string>()
}
});
});
var cfg = builder.Configuration;
var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing");
var issuer = cfg["Jwt:Issuer"] ?? "promiscuity";
var aud = cfg["Jwt:Audience"] ?? issuer;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudience = aud,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
o.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var userId = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
{
context.Fail("User identity missing");
return;
}
var services = context.HttpContext.RequestServices;
var userTask = services.GetRequiredService<AuthUserStore>().GetByIdAsync(userId);
var deletedTask = services.GetRequiredService<UserProfileStore>().IsDeletedAsync(userId);
var jti = context.Principal?.FindFirstValue(JwtRegisteredClaimNames.Jti);
var revokedTask = string.IsNullOrWhiteSpace(jti)
? Task.FromResult(false)
: services.GetRequiredService<TokenBlacklistStore>().ContainsAsync(jti);
await Task.WhenAll(userTask, deletedTask, revokedTask);
var user = await userTask;
var tokenVersion = int.TryParse(context.Principal?.FindFirstValue("auth_ver"), out var parsedVersion)
? parsedVersion
: 0;
if (user is null || user.AuthVersion != tokenVersion)
context.Fail("Session revoked");
else if (await deletedTask)
context.Fail("Account deleted");
else if (await revokedTask)
context.Fail("Token revoked");
}
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
await app.Services.GetRequiredService<AuthDataInitializer>().InitializeAsync();
app.UseForwardedHeaders();
app.Use(async (context, next) =>
{
var started = System.Diagnostics.Stopwatch.StartNew();
try
{
await next();
}
finally
{
context.RequestServices.GetRequiredService<ServiceMetrics>().HttpRequest(started.Elapsed);
}
});
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var feature = context.Features.Get<IExceptionHandlerFeature>();
var exception = feature?.Error;
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("GlobalException");
var traceId = context.TraceIdentifier;
if (exception is not null)
{
logger.LogError(
exception,
"Unhandled exception for {Method} {Path}. TraceId={TraceId}",
context.Request.Method,
context.Request.Path,
traceId
);
}
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new
{
type = "https://httpstatuses.com/500",
title = "Internal Server Error",
status = 500,
detail = exception?.Message ?? "An unexpected server error occurred.",
traceId
});
});
});
app.MapGet("/healthz", () => Results.Ok("ok"));
app.MapGet("/readyz", async (MongoDatabaseProvider mongo) =>
{
await mongo.Database.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
return Results.Ok("ready");
});
app.MapGet("/api/runtime", (IConfiguration configuration) => Results.Ok(new
{
environment = configuration["Deployment:Environment"] ?? "development",
buildRevision = configuration["Deployment:BuildRevision"] ?? "development"
}));
app.MapGet("/metrics", (ServiceMetrics metrics) => Results.Text(metrics.RenderPrometheus(), "text/plain; version=0.0.4"));
app.UseSwagger();
app.UseSwaggerUI(o =>
{
o.SwaggerEndpoint("/swagger/v1/swagger.json", "Questions API v1");
o.RoutePrefix = "swagger";
});
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();