feat: add runtime logging cors and zlinq foundation

This commit is contained in:
xiong
2026-07-26 13:39:06 +08:00
parent f0553a7919
commit f4783c5de3
10 changed files with 340 additions and 104 deletions

View File

@@ -0,0 +1,45 @@
using Serilog;
using Serilog.AspNetCore;
using Serilog.Events;
using Tiku.Application.Security;
namespace Tiku.Api.Logging;
public static class SerilogRequestLogging
{
public static void ConfigureRequestLogging(RequestLoggingOptions options)
{
options.GetLevel = (httpContext, elapsed, exception) =>
exception is not null || httpContext.Response.StatusCode >= StatusCodes.Status500InternalServerError
? LogEventLevel.Error
: elapsed > 1000 || httpContext.Response.StatusCode >= StatusCodes.Status400BadRequest
? LogEventLevel.Warning
: LogEventLevel.Information;
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
diagnosticContext.Set("TraceId", httpContext.TraceIdentifier);
var user = httpContext.User;
SetClaim(diagnosticContext, "UserId", user, TikuClaimTypes.UserId);
SetClaim(diagnosticContext, "TenantId", user, TikuClaimTypes.TenantId);
SetClaim(diagnosticContext, "SessionId", user, TikuClaimTypes.SessionId);
SetClaim(diagnosticContext, "TenantRole", user, TikuClaimTypes.TenantRole);
};
}
private static void SetClaim(
IDiagnosticContext diagnosticContext,
string propertyName,
System.Security.Claims.ClaimsPrincipal principal,
string claimType)
{
var value = principal.FindFirst(claimType)?.Value;
if (!string.IsNullOrWhiteSpace(value))
{
diagnosticContext.Set(propertyName, value);
}
}
}

View File

@@ -0,0 +1,12 @@
namespace Tiku.Api.Options;
public sealed class CorsOptions
{
public const string SectionName = "Cors";
public const string PolicyName = "TikuCors";
public string[] AllowedOrigins { get; set; } = [];
public string[] AllowedHeaders { get; set; } = ["Authorization", "Content-Type", "x-tenant-code"];
public string[] AllowedMethods { get; set; } = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
public bool AllowCredentials { get; set; }
}

View File

@@ -2,125 +2,185 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
using Serilog;
using Serilog.Events;
using System.Text;
using System.Text.Json.Serialization;
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;
var builder = WebApplication.CreateBuilder(args);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateBootstrapLogger();
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddOpenApi(options =>
try
{
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
});
builder.Services.AddProblemDetails();
builder.Services.AddApplication();
Log.Information("Starting TIKU API");
var connectionString =
builder.Configuration.GetConnectionString("Database") ??
builder.Configuration["DATABASE_URL"] ??
"Host=localhost;Database=tiku;Username=postgres";
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog((services, configuration) => configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext(),
preserveStaticLogger: true);
builder.Services.AddInfrastructure(connectionString);
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Security:Jwt"));
var jwtOptions = builder.Configuration
.GetSection("Security:Jwt")
.Get<JwtOptions>() ?? new JwtOptions();
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddOpenApi(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
});
builder.Services.AddProblemDetails();
builder.Services.AddApplication();
builder.Services.Configure<CorsOptions>(builder.Configuration.GetSection(CorsOptions.SectionName));
var corsOptions = builder.Configuration
.GetSection(CorsOptions.SectionName)
.Get<CorsOptions>() ?? new CorsOptions();
builder.Services.AddCors(options =>
{
options.AddPolicy(CorsOptions.PolicyName, policy =>
{
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 =>
var origins = corsOptions.AllowedOrigins
.Where(origin => !string.IsNullOrWhiteSpace(origin))
.Select(origin => origin.Trim().TrimEnd('/'))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (origins.Length > 0)
{
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.");
}
policy.WithOrigins(origins);
}
};
policy
.WithHeaders(corsOptions.AllowedHeaders)
.WithMethods(corsOptions.AllowedMethods);
if (corsOptions.AllowCredentials)
{
policy.AllowCredentials();
}
});
});
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 connectionString =
builder.Configuration.GetConnectionString("Database") ??
builder.Configuration["DATABASE_URL"] ??
"Host=localhost;Database=tiku;Username=postgres";
var app = builder.Build();
builder.Services.AddInfrastructure(connectionString);
if (app.Environment.IsDevelopment())
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Security:Jwt"));
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.UseCors(CorsOptions.PolicyName);
app.UseAuthentication();
app.UseMiddleware<CurrentPrincipalMiddleware>();
app.UseAuthorization();
app.MapControllers();
app.Run();
}
catch (Exception exception)
{
app.MapOpenApi();
app.MapScalarApiReference(options => options
.WithTitle("TIKU Backend API")
.AddPreferredSecuritySchemes("BearerAuth")
.EnablePersistentAuthentication());
Log.Fatal(exception, "TIKU API terminated unexpectedly");
throw;
}
finally
{
Log.CloseAndFlush();
}
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseMiddleware<CurrentPrincipalMiddleware>();
app.UseAuthorization();
app.MapControllers();
app.Run();
public partial class Program;

View File

@@ -1,6 +1,7 @@
using System.Security.Claims;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
using ZLinq;
namespace Tiku.Api.Security;
@@ -22,7 +23,7 @@ internal static class TenantRoleAuthorization
public static bool IsTenantAdmin(ClaimsPrincipal principal)
{
return IsTenantMember(principal) &&
principal.Claims
principal.Claims.AsValueEnumerable()
.Where(claim => claim.Type == TikuClaimTypes.TenantRole)
.Select(claim => claim.Value)
.Any(role => AdminRoles.Contains(role));

View File

@@ -16,7 +16,13 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Enrichers.Environment" />
<PackageReference Include="Serilog.Enrichers.Thread" />
<PackageReference Include="Serilog.Settings.Configuration" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="ZLinq" />
</ItemGroup>
</Project>

View File

@@ -1,8 +1,22 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
"System.Net.Http.HttpClient": "Warning"
}
}
},
"Cors": {
"AllowedOrigins": [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:3000",
"http://127.0.0.1:3000"
],
"AllowCredentials": false
}
}

View File

@@ -1,10 +1,48 @@
{
"Logging": {
"LogLevel": {
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithThreadId"
],
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} {Message:lj} {Properties:j}{NewLine}{Exception}"
}
}
],
"Properties": {
"Application": "Tiku.Api"
}
},
"Cors": {
"AllowedOrigins": [],
"AllowedHeaders": [
"Authorization",
"Content-Type",
"x-tenant-code"
],
"AllowedMethods": [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS"
],
"AllowCredentials": false
},
"Security": {
"Jwt": {
"Issuer": "tiku-backend",