Files
tiku-backend.net/Tiku.Api/Program.cs

254 lines
9.1 KiB
C#

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
using Serilog;
using Serilog.Events;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Tiku.Api.Logging;
using Tiku.Api.Middleware;
using Tiku.Api.OpenApi;
using Tiku.Api.Options;
using Tiku.Api.Security;
using Tiku.Application;
using Tiku.Application.Security;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Persistence;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
Log.Information("Starting TIKU API");
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog((services, configuration) => configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext(),
preserveStaticLogger: true);
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddOpenApi(options =>
{
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
});
builder.Services.AddProblemDetails();
builder.Services.AddApplication();
builder.Services.AddOptions<CorsOptions>()
.Bind(builder.Configuration.GetSection(CorsOptions.SectionName))
.ValidateDataAnnotations()
.Validate(OptionsValidation.BeValidCorsOptions, "CORS origins must be absolute HTTP/HTTPS origins, and credentials require explicit origins.")
.ValidateOnStart();
var corsOptions = builder.Configuration
.GetSection(CorsOptions.SectionName)
.Get<CorsOptions>() ?? new CorsOptions();
builder.Services.AddCors(options =>
{
options.AddPolicy(CorsOptions.PolicyName, policy =>
{
var origins = corsOptions.AllowedOrigins
.Where(origin => !string.IsNullOrWhiteSpace(origin))
.Select(origin => origin.Trim().TrimEnd('/'))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (origins.Length > 0)
{
policy.WithOrigins(origins);
}
policy
.WithHeaders(corsOptions.AllowedHeaders)
.WithMethods(corsOptions.AllowedMethods);
if (corsOptions.AllowCredentials)
{
policy.AllowCredentials();
}
});
});
builder.Services.AddOptions<ApiRateLimitOptions>()
.Bind(builder.Configuration.GetSection(ApiRateLimitOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
var rateLimitOptions = builder.Configuration
.GetSection(ApiRateLimitOptions.SectionName)
.Get<ApiRateLimitOptions>() ?? new ApiRateLimitOptions();
if (rateLimitOptions.Enabled)
{
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var partitionKey =
httpContext.User.FindFirst(TikuClaimTypes.UserId)?.Value ??
httpContext.Connection.RemoteIpAddress?.ToString() ??
"anonymous";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey,
_ => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = rateLimitOptions.PermitLimit,
QueueLimit = rateLimitOptions.QueueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
Window = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds)
});
});
options.OnRejected = async (context, cancellationToken) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
}
var problem = new ProblemDetails
{
Title = "Too many requests.",
Status = StatusCodes.Status429TooManyRequests,
Instance = context.HttpContext.Request.Path
};
problem.Extensions["code"] = "rate_limited";
problem.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken);
};
});
}
var connectionString =
builder.Configuration.GetConnectionString("Database") ??
builder.Configuration["DATABASE_URL"] ??
"Host=localhost;Database=tiku;Username=postgres";
builder.Services.AddInfrastructure(connectionString);
builder.Services.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection("Security:Jwt"))
.ValidateDataAnnotations()
.ValidateOnStart();
var jwtOptions = builder.Configuration
.GetSection("Security:Jwt")
.Get<JwtOptions>() ?? new JwtOptions();
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtOptions.Issuer,
ValidateAudience = true,
ValidAudience = jwtOptions.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
if (!jwtOptions.ValidateSessions)
{
return;
}
var sessionIdValue = context.Principal?.FindFirst(TikuClaimTypes.SessionId)?.Value;
if (!Guid.TryParse(sessionIdValue, out var sessionId))
{
context.Fail("Missing session claim.");
return;
}
var dbContext = context.HttpContext.RequestServices.GetRequiredService<TikuDbContext>();
var now = DateTimeOffset.UtcNow;
var isSessionActive = await dbContext.AuthSessions.AnyAsync(
session =>
session.Id == sessionId &&
session.RevokedAt == null &&
session.ExpiresAt > now);
if (!isSessionActive)
{
context.Fail("Session has been revoked or expired.");
}
}
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(
TikuPolicies.AuthenticatedUser,
policy => policy.RequireAuthenticatedUser());
options.AddPolicy(
TikuPolicies.CurrentTenantMember,
policy => policy
.RequireAuthenticatedUser()
.RequireAssertion(context => TenantRoleAuthorization.IsTenantMember(context.User)));
options.AddPolicy(
TikuPolicies.TenantAdmin,
policy => policy
.RequireAuthenticatedUser()
.RequireAssertion(context => TenantRoleAuthorization.IsTenantAdmin(context.User)));
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference(options => options
.WithTitle("TIKU Backend API")
.AddPreferredSecuritySchemes("BearerAuth")
.EnablePersistentAuthentication());
}
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors(CorsOptions.PolicyName);
app.UseAuthentication();
if (rateLimitOptions.Enabled)
{
app.UseRateLimiter();
}
app.UseMiddleware<CurrentPrincipalMiddleware>();
app.UseAuthorization();
app.MapControllers();
app.Run();
}
catch (Exception exception)
{
Log.Fatal(exception, "TIKU API terminated unexpectedly");
throw;
}
finally
{
Log.CloseAndFlush();
}
public partial class Program;