From b8c6f058057ebcee3d675543be6bb6446fc178c1 Mon Sep 17 00:00:00 2001 From: cryptooda Date: Sat, 1 Nov 2025 18:01:08 +0700 Subject: [PATCH] Update managing api security --- src/Managing.Api/Authorization/JwtUtils.cs | 26 ++- .../Controllers/TradingController.cs | 14 +- src/Managing.Api/Managing.Api.csproj | 55 +++--- src/Managing.Api/Program.cs | 182 +++++++++++++++--- src/Managing.Api/appsettings.KaiServer.json | 5 +- src/Managing.Api/appsettings.Production.json | 60 +++++- src/Managing.Api/appsettings.json | 4 +- .../Shared/WebhookService.cs | 12 ++ 8 files changed, 296 insertions(+), 62 deletions(-) diff --git a/src/Managing.Api/Authorization/JwtUtils.cs b/src/Managing.Api/Authorization/JwtUtils.cs index ad60090e..85d8da9f 100644 --- a/src/Managing.Api/Authorization/JwtUtils.cs +++ b/src/Managing.Api/Authorization/JwtUtils.cs @@ -1,8 +1,8 @@ -using Managing.Domain.Users; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; +using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; +using Managing.Domain.Users; +using Microsoft.IdentityModel.Tokens; namespace Managing.Api.Authorization; @@ -16,21 +16,31 @@ public interface IJwtUtils public class JwtUtils : IJwtUtils { private readonly string _secret; + private readonly string? _issuer; + private readonly string? _audience; + public JwtUtils(IConfiguration config) { - _secret = config.GetValue("Jwt:Secret"); + _secret = config.GetValue("Jwt:Secret") + ?? throw new InvalidOperationException("JWT secret is not configured."); + _issuer = config.GetValue("Authentication:Schemes:Bearer:ValidIssuer"); + _audience = config.GetValue("Authentication:Schemes:Bearer:ValidAudiences"); } public string GenerateJwtToken(User user, string publicAddress) { - // generate token that is valid for 15 minutes + // Generate token that is valid for 15 days (as per original implementation) var tokenHandler = new JwtSecurityTokenHandler(); - var key = Encoding.ASCII.GetBytes(_secret); + var key = Encoding.UTF8.GetBytes(_secret); // Use UTF8 consistently with Program.cs var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(new[] { new Claim("address", publicAddress) }), Expires = DateTime.UtcNow.AddDays(15), - SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) + Issuer = _issuer, // Include issuer if configured + Audience = _audience, // Include audience if configured + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256Signature) }; var token = tokenHandler.CreateToken(tokenDescriptor); return tokenHandler.WriteToken(token); @@ -42,7 +52,7 @@ public class JwtUtils : IJwtUtils return null; var tokenHandler = new JwtSecurityTokenHandler(); - var key = Encoding.ASCII.GetBytes(_secret); + var key = Encoding.UTF8.GetBytes(_secret); // Use UTF8 consistently with Program.cs try { tokenHandler.ValidateToken(token, new TokenValidationParameters diff --git a/src/Managing.Api/Controllers/TradingController.cs b/src/Managing.Api/Controllers/TradingController.cs index b3ff824b..95a3ee17 100644 --- a/src/Managing.Api/Controllers/TradingController.cs +++ b/src/Managing.Api/Controllers/TradingController.cs @@ -1,4 +1,6 @@ -using Managing.Api.Models.Requests; +using System.Net.Http.Headers; +using System.Text; +using Managing.Api.Models.Requests; using Managing.Application.Abstractions; using Managing.Application.Abstractions.Services; using Managing.Application.Shared; @@ -289,6 +291,16 @@ public class TradingController : BaseController var httpClient = _httpClientFactory.CreateClient(); + // Add basic authentication if credentials are provided + var username = _configuration["N8n:Username"]; + var password = _configuration["N8n:Password"]; + + if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password)) + { + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } + _logger.LogInformation( "Submitting indicator request: {IndicatorName} - {Strategy} by {Requester}", request.IndicatorName, diff --git a/src/Managing.Api/Managing.Api.csproj b/src/Managing.Api/Managing.Api.csproj index 2685c37d..56060220 100644 --- a/src/Managing.Api/Managing.Api.csproj +++ b/src/Managing.Api/Managing.Api.csproj @@ -11,35 +11,36 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + @@ -61,6 +62,6 @@ - + diff --git a/src/Managing.Api/Program.cs b/src/Managing.Api/Program.cs index e1a34789..f8589e26 100644 --- a/src/Managing.Api/Program.cs +++ b/src/Managing.Api/Program.cs @@ -1,6 +1,6 @@ -using System.Security.Claims; using System.Text; using System.Text.Json.Serialization; +using AspNetCoreRateLimit; using HealthChecks.UI.Client; using Managing.Api.Authorization; using Managing.Api.Filters; @@ -15,6 +15,8 @@ using Managing.Infrastructure.Databases.PostgreSql; using Managing.Infrastructure.Databases.PostgreSql.Configurations; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.IdentityModel.Tokens; @@ -175,9 +177,64 @@ builder.Host.UseSerilog((hostBuilder, loggerConfiguration) => builder.Services.AddOptions(); builder.Services.Configure(builder.Configuration.GetSection(Constants.Databases.PostgreSql)); builder.Services.Configure(builder.Configuration.GetSection(Constants.Databases.InfluxDb)); + +// Configure Request Size Limits (protect against DoS attacks) +builder.Services.Configure(options => +{ + options.MultipartBodyLengthLimit = 10485760; // 10MB + options.ValueLengthLimit = 10485760; // 10MB + options.MultipartBoundaryLengthLimit = 256; + options.KeyLengthLimit = 2048; +}); + +builder.Services.Configure(options => +{ + options.Limits.MaxRequestBodySize = 10485760; // 10MB + options.Limits.MaxRequestHeadersTotalSize = 32768; // 32KB + options.Limits.MaxConcurrentConnections = 100; + options.Limits.MaxConcurrentUpgradedConnections = 100; + options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30); +}); + +// Configure Rate Limiting +builder.Services.AddMemoryCache(); +builder.Services.Configure(builder.Configuration.GetSection("IpRateLimiting")); +builder.Services.Configure(builder.Configuration.GetSection("IpRateLimitPolicies")); +builder.Services.AddInMemoryRateLimiting(); +builder.Services.AddSingleton(); + builder.Services.AddControllers().AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); +// Validate JWT secret configuration +var jwtSecret = builder.Configuration["Jwt:Secret"]; +if (string.IsNullOrWhiteSpace(jwtSecret)) +{ + throw new InvalidOperationException( + "JWT secret is not configured. Please set 'Jwt:Secret' in appsettings.json or environment variables."); +} + +if (jwtSecret.Length < 32) +{ + throw new InvalidOperationException( + $"JWT secret must be at least 32 characters long. Current length: {jwtSecret.Length}. " + + "This is a security requirement for production environments."); +} + +// Get issuer and audience configuration +var validIssuer = builder.Configuration["Authentication:Schemes:Bearer:ValidIssuer"]; +var validAudience = builder.Configuration["Authentication:Schemes:Bearer:ValidAudiences"]; + +// Determine if validation should be enabled (enable in production, allow override via config) +var enableIssuerValidation = builder.Configuration.GetValue("Jwt:ValidateIssuer", + !builder.Environment.IsDevelopment()); +var enableAudienceValidation = builder.Configuration.GetValue("Jwt:ValidateAudience", + !builder.Environment.IsDevelopment()); + +// Configure clock skew (tolerance for time differences between servers) +var clockSkewSeconds = builder.Configuration.GetValue("Jwt:ClockSkewSeconds", 0); +var clockSkew = TimeSpan.FromSeconds(clockSkewSeconds); + builder.Services .AddAuthentication(options => { @@ -189,13 +246,16 @@ builder.Services o.SaveToken = true; o.TokenValidationParameters = new TokenValidationParameters { - ValidIssuer = builder.Configuration["Authentication:Schemes:Bearer:ValidIssuer"], - ValidAudience = builder.Configuration["Authentication:Schemes:Bearer:ValidAudiences"], - IssuerSigningKey = new SymmetricSecurityKey - (Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"])), - ValidateIssuer = false, - ValidateAudience = false, - ValidateIssuerSigningKey = true + ValidIssuer = validIssuer, + ValidAudience = validAudience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), + ValidateIssuerSigningKey = true, + ValidateIssuer = enableIssuerValidation && !string.IsNullOrWhiteSpace(validIssuer), + ValidateAudience = enableAudienceValidation && !string.IsNullOrWhiteSpace(validAudience), + ValidateLifetime = true, // Explicitly validate token expiration + ClockSkew = clockSkew, // Configure clock skew tolerance + RequireExpirationTime = true, // Ensure tokens have expiration + RequireSignedTokens = true // Ensure tokens are signed }; o.Events = new JwtBearerEvents { @@ -212,9 +272,20 @@ builder.Services }, OnAuthenticationFailed = context => { - if (context.Exception.GetType() == typeof(SecurityTokenExpiredException)) + var logger = context.HttpContext.RequestServices + .GetService>(); + + if (context.Exception is SecurityTokenExpiredException) { - context.Response.Headers.Add("Token-Expired", "true"); + context.Response.Headers["Token-Expired"] = "true"; + logger?.LogWarning("JWT token expired for request: {Path}", + context.Request.Path); + } + else + { + logger?.LogError(context.Exception, + "JWT authentication failed for request: {Path}", + context.Request.Path); } return Task.CompletedTask; @@ -222,19 +293,46 @@ builder.Services // --- IMPORTANT: Attach User to Context Here --- OnTokenValidated = async context => { - var userService = context.HttpContext.RequestServices.GetRequiredService(); - // Assuming your JWT token contains a 'nameid' claim (or similar) for the user ID - var userId = context.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; - - if (!string.IsNullOrEmpty(userId)) + var logger = context.HttpContext.RequestServices + .GetService>(); + + try { - // Fetch the full user object from your service - var user = await userService.GetUserByAddressAsync(userId); - if (user != null) + var userService = context.HttpContext.RequestServices + .GetRequiredService(); + + // JWT token contains 'address' claim (not NameIdentifier) + var address = context.Principal.FindFirst("address")?.Value; + + if (!string.IsNullOrEmpty(address)) { - // Attach the user object to HttpContext.Items - context.HttpContext.Items["User"] = user; + // Fetch the full user object from your service + var user = await userService.GetUserByAddressAsync(address); + if (user != null) + { + // Attach the user object to HttpContext.Items + context.HttpContext.Items["User"] = user; + logger?.LogDebug("User {Address} authenticated successfully", address); + } + else + { + logger?.LogWarning( + "JWT token validated but user not found for address: {Address}", + address); + context.Fail("User not found"); + } } + else + { + logger?.LogWarning("JWT token validated but 'address' claim not found"); + context.Fail("Invalid token: missing address claim"); + } + } + catch (Exception ex) + { + logger?.LogError(ex, + "Error during JWT token validation - user lookup failed"); + context.Fail("Authentication failed: user lookup error"); } await Task.CompletedTask; @@ -248,6 +346,7 @@ var allowedCorsOrigins = builder.Configuration .GetSection("Cors:AllowedOrigins") .Get() ?? Array.Empty(); +// Configure CORS with improved security builder.Services.AddCors(options => { options.AddPolicy("CorsPolicy", policy => @@ -256,9 +355,11 @@ builder.Services.AddCors(options => { policy .WithOrigins(allowedCorsOrigins) - .AllowAnyMethod() - .AllowAnyHeader() - .AllowCredentials(); + .WithMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + .WithHeaders("Content-Type", "Authorization", "X-Requested-With", "X-Correlation-ID") + .WithExposedHeaders("Token-Expired", "X-Correlation-ID") + .AllowCredentials() + .SetPreflightMaxAge(TimeSpan.FromHours(24)); } else { @@ -331,6 +432,41 @@ builder.WebHost.SetupDiscordBot(); var app = builder.Build(); app.UseSerilogRequestLogging(); + +// Rate Limiting - must be before authentication/authorization +app.UseIpRateLimiting(); + +// Security Headers Middleware - add early in pipeline +app.Use(async (context, next) => +{ + // Security headers for all responses + context.Response.Headers.Append("X-Content-Type-Options", "nosniff"); + context.Response.Headers.Append("X-Frame-Options", "DENY"); + context.Response.Headers.Append("X-XSS-Protection", "1; mode=block"); + context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin"); + context.Response.Headers.Append("Permissions-Policy", "geolocation=(), microphone=(), camera=()"); + + // Content Security Policy - only for non-Swagger endpoints + if (!context.Request.Path.StartsWithSegments("/swagger") && + !context.Request.Path.StartsWithSegments("/health") && + !context.Request.Path.StartsWithSegments("/alive")) + { + context.Response.Headers.Append("Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;"); + } + + // Remove server header (optional - Kestrel can be configured separately) + context.Response.Headers.Remove("Server"); + + await next(); +}); + +// HSTS - HTTP Strict Transport Security (production only) +if (!app.Environment.IsDevelopment()) +{ + app.UseHsts(); +} + if (enableSwagger) { app.UseOpenApi(); diff --git a/src/Managing.Api/appsettings.KaiServer.json b/src/Managing.Api/appsettings.KaiServer.json index 1d88b4cf..446b3751 100644 --- a/src/Managing.Api/appsettings.KaiServer.json +++ b/src/Managing.Api/appsettings.KaiServer.json @@ -9,7 +9,10 @@ "Token": "eOuXcXhH7CS13Iw4CTiDDpRjIjQtEVPOloD82pLPOejI4n0BsEj1YzUw0g3Cs1mdDG5m-RaxCavCMsVTtS5wIQ==" }, "N8n": { - "WebhookUrl": "https://n8n.kai.managing.live/webhook/fa9308b6-983b-42ec-b085-71599d655951" + "WebhookUrl": "https://n8n.kai.managing.live/webhook/fa9308b6-983b-42ec-b085-71599d655951", + "IndicatorRequestWebhookUrl": "https://n8n.kai.managing.live/webhook/3aa07b66-1e64-46a7-8618-af300914cb11", + "Username": "", + "Password": "" }, "Serilog": { "MinimumLevel": { diff --git a/src/Managing.Api/appsettings.Production.json b/src/Managing.Api/appsettings.Production.json index 9a86722c..21ed938b 100644 --- a/src/Managing.Api/appsettings.Production.json +++ b/src/Managing.Api/appsettings.Production.json @@ -23,12 +23,17 @@ } } }, - "AllowedHosts": "*", "Kaigen": { "BaseUrl": "https://api.kaigen.managing.live", "DebitEndpoint": "/api/credits/debit", "RefundEndpoint": "/api/credits/refund" }, + "N8n": { + "WebhookUrl": "https://n8n.kai.managing.live/webhook/fa9308b6-983b-42ec-b085-71599d655951", + "IndicatorRequestWebhookUrl": "https://n8n.kai.managing.live/webhook/3aa07b66-1e64-46a7-8618-af300914cb11", + "Username": "managing-api", + "Password": "mH%g5qr!WvCd6%9Fck22Xo" + }, "SqlMonitoring": { "Enabled": true, "LoggingEnabled": false, @@ -41,5 +46,58 @@ "https://app.kaigen.ai", "https://api.kaigen.ai" ] + }, + "Authentication": { + "Schemes": { + "Bearer": { + "ValidIssuer": "https://api.kaigen.ai", + "ValidAudiences": "https://app.kaigen.ai" + } + } + }, + "Jwt": { + "ValidateIssuer": true, + "ValidateAudience": true, + "ClockSkewSeconds": 0 + }, + "IpRateLimiting": { + "EnableEndpointRateLimiting": true, + "StackBlockedRequests": false, + "RealIpHeader": "X-Real-IP", + "ClientIdHeader": "X-ClientId", + "HttpStatusCode": 429, + "IpWhitelist": [], + "EndpointWhitelist": [], + "ClientWhitelist": [], + "GeneralRules": [ + { + "Endpoint": "*", + "Period": "1m", + "Limit": 100 + }, + { + "Endpoint": "POST:/user/create-token", + "Period": "1m", + "Limit": 5 + }, + { + "Endpoint": "POST:*", + "Period": "1m", + "Limit": 30 + } + ] + }, + "IpRateLimitPolicies": { + "IpRules": [], + "ClientRules": [], + "EndpointRules": [] + }, + "Kestrel": { + "Limits": { + "MaxRequestBodySize": 10485760, + "MaxRequestHeadersTotalSize": 32768, + "MaxConcurrentConnections": 100, + "MaxConcurrentUpgradedConnections": 100 + } } } \ No newline at end of file diff --git a/src/Managing.Api/appsettings.json b/src/Managing.Api/appsettings.json index cf1bef0c..f701223c 100644 --- a/src/Managing.Api/appsettings.json +++ b/src/Managing.Api/appsettings.json @@ -33,7 +33,9 @@ }, "N8n": { "WebhookUrl": "https://n8n.kai.managing.live/webhook/fa9308b6-983b-42ec-b085-71599d655951", - "IndicatorRequestWebhookUrl": "https://n8n.kai.managing.live/webhook/3aa07b66-1e64-46a7-8618-af300914cb11" + "IndicatorRequestWebhookUrl": "https://n8n.kai.managing.live/webhook/3aa07b66-1e64-46a7-8618-af300914cb11", + "Username": "", + "Password": "" }, "Sentry": { "Dsn": "https://fe12add48c56419bbdfa86227c188e7a@glitch.kai.managing.live/1", diff --git a/src/Managing.Application/Shared/WebhookService.cs b/src/Managing.Application/Shared/WebhookService.cs index ffe35273..5d9dc0d5 100644 --- a/src/Managing.Application/Shared/WebhookService.cs +++ b/src/Managing.Application/Shared/WebhookService.cs @@ -1,4 +1,6 @@ +using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text; using Managing.Application.Abstractions.Services; using Managing.Common; using Managing.Domain.Users; @@ -20,6 +22,16 @@ public class WebhookService : IWebhookService _configuration = configuration; _logger = logger; _n8nWebhookUrl = _configuration["N8n:WebhookUrl"] ?? string.Empty; + + // Configure basic authentication if credentials are provided + var username = _configuration["N8n:Username"]; + var password = _configuration["N8n:Password"]; + + if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password)) + { + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } } public async Task SendTradeNotification(User user, string message, bool isBadBehavior = false)