diff --git a/20250910_130832_35784.gcdump b/20250910_130832_35784.gcdump deleted file mode 100755 index e861cf6..0000000 Binary files a/20250910_130832_35784.gcdump and /dev/null differ diff --git a/MySvelteApp.Server/Infrastructure/Authentication/JwtOptions.cs b/MySvelteApp.Server/Infrastructure/Authentication/JwtOptions.cs new file mode 100644 index 0000000..a57f1f1 --- /dev/null +++ b/MySvelteApp.Server/Infrastructure/Authentication/JwtOptions.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; +using System.Text; + +namespace MySvelteApp.Server.Infrastructure.Authentication; + +/// +/// Public validator class for JwtOptions to support DataAnnotations validation. +/// +public static class JwtOptionsValidator +{ + /// + /// Validates that a string value is not null, empty, or whitespace-only. + /// + public static ValidationResult ValidateNotWhitespace(object? value, ValidationContext context) + { + var str = value as string; + if (string.IsNullOrWhiteSpace(str)) + { + var displayName = context.DisplayName ?? context.MemberName ?? "Value"; + return new ValidationResult($"{displayName} cannot be blank or whitespace-only."); + } + return ValidationResult.Success!; + } + + public static ValidationResult ValidateKeyStrength(object? value, ValidationContext context) + { + var s = value as string ?? string.Empty; + byte[] bytes = DeriveKeyBytes(s); + return bytes.Length >= 32 + ? ValidationResult.Success! + : new ValidationResult("Jwt:Key must be at least 32 bytes (256-bit) after decoding."); + } + + private static byte[] DeriveKeyBytes(string key) => + key.StartsWith("base64:", StringComparison.Ordinal) + ? Convert.FromBase64String(key["base64:".Length..]) + : Encoding.UTF8.GetBytes(key); +} + +public sealed class JwtOptions +{ + [Required] + [MinLength(32, ErrorMessage = "Jwt:Key must be at least 32 characters long.")] + [CustomValidation(typeof(JwtOptionsValidator), nameof(JwtOptionsValidator.ValidateKeyStrength))] + public string Key { get; set; } = string.Empty; + + [Required] + [CustomValidation(typeof(JwtOptionsValidator), nameof(JwtOptionsValidator.ValidateNotWhitespace))] + public string Issuer { get; set; } = string.Empty; + + [Required] + [CustomValidation(typeof(JwtOptionsValidator), nameof(JwtOptionsValidator.ValidateNotWhitespace))] + public string Audience { get; set; } = string.Empty; + + /// + /// Lifetime of the access token in hours. + /// + [Range(1, 168, ErrorMessage = "Jwt:AccessTokenLifetimeHours must be between 1 and 168 hours.")] + public int AccessTokenLifetimeHours { get; set; } = 24; +} diff --git a/MySvelteApp.Server/Infrastructure/Authentication/JwtTokenGenerator.cs b/MySvelteApp.Server/Infrastructure/Authentication/JwtTokenGenerator.cs index 17cef23..4627718 100644 --- a/MySvelteApp.Server/Infrastructure/Authentication/JwtTokenGenerator.cs +++ b/MySvelteApp.Server/Infrastructure/Authentication/JwtTokenGenerator.cs @@ -1,6 +1,8 @@ +using System.Globalization; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using MySvelteApp.Server.Application.Common.Interfaces; using MySvelteApp.Server.Domain.Entities; @@ -9,32 +11,40 @@ namespace MySvelteApp.Server.Infrastructure.Authentication; public class JwtTokenGenerator : IJwtTokenGenerator { - private readonly IConfiguration _configuration; + private readonly JwtOptions _jwtOptions; - public JwtTokenGenerator(IConfiguration configuration) + public JwtTokenGenerator(IOptions jwtOptions) { - _configuration = configuration; + _jwtOptions = jwtOptions.Value; } public string GenerateToken(User user) { - var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"] ?? "your-secret-key-here")); + var keyBytes = DeriveKeyBytes(_jwtOptions.Key); + var securityKey = new SymmetricSecurityKey(keyBytes); var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); var claims = new[] { - new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString(CultureInfo.InvariantCulture)), + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString(CultureInfo.InvariantCulture)), new Claim(ClaimTypes.Name, user.Username), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim(JwtRegisteredClaimNames.Iat, EpochTime.GetIntDate(DateTime.UtcNow).ToString(CultureInfo.InvariantCulture), ClaimValueTypes.Integer64) }; var token = new JwtSecurityToken( - issuer: _configuration["Jwt:Issuer"] ?? "your-issuer", - audience: _configuration["Jwt:Audience"] ?? "your-audience", + issuer: _jwtOptions.Issuer, + audience: _jwtOptions.Audience, claims: claims, - expires: DateTime.UtcNow.AddHours(24), + expires: DateTime.UtcNow.AddHours(_jwtOptions.AccessTokenLifetimeHours), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); } + + private static byte[] DeriveKeyBytes(string key) => + key.StartsWith("base64:", StringComparison.Ordinal) + ? Convert.FromBase64String(key["base64:".Length..]) + : Encoding.UTF8.GetBytes(key); } diff --git a/MySvelteApp.Server/MySvelteApp.Server.csproj b/MySvelteApp.Server/MySvelteApp.Server.csproj index 419d4b0..5117a36 100755 --- a/MySvelteApp.Server/MySvelteApp.Server.csproj +++ b/MySvelteApp.Server/MySvelteApp.Server.csproj @@ -6,6 +6,13 @@ enable MySvelteApp.Server $(AssemblyName.Replace(' ', '_')) + + false + $(NoWarn) + + + true + $(NoWarn);1591 ../MySvelteApp.Client npm run dev diff --git a/MySvelteApp.Server/Program.cs b/MySvelteApp.Server/Program.cs index d308766..4b70fb6 100755 --- a/MySvelteApp.Server/Program.cs +++ b/MySvelteApp.Server/Program.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using MySvelteApp.Server.Application.Authentication; @@ -19,6 +20,9 @@ using OpenTelemetry.Trace; using Serilog; using Serilog.Sinks.Grafana.Loki; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; var builder = WebApplication.CreateBuilder(args); @@ -29,34 +33,58 @@ options.AddPolicy(WebsiteClientOrigin, policy => { policy - .WithOrigins("http://localhost:5173", "http://localhost:3000", "http://web:3000", "http://localhost:5173") + .WithOrigins("http://localhost:5173", "http://localhost:3000", "http://web:3000") .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); }); }); +// Bind and validate JwtOptions +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Jwt")) + .ValidateDataAnnotations() + .Validate(o => !string.IsNullOrWhiteSpace(o.Key), "Jwt:Key cannot be blank/whitespace.") + .Validate(o => !string.IsNullOrWhiteSpace(o.Issuer), "Jwt:Issuer cannot be blank/whitespace.") + .Validate(o => !string.IsNullOrWhiteSpace(o.Audience), "Jwt:Audience cannot be blank/whitespace.") + .ValidateOnStart(); + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => + .AddJwtBearer(); + +builder.Services + .AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure>((options, jwt) => { + var o = jwt.Value; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, - ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "your-issuer", - ValidAudience = builder.Configuration["Jwt:Audience"] ?? "your-audience", - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"] ?? "your-secret-key-here")) + ValidIssuer = o.Issuer, + ValidAudience = o.Audience, + IssuerSigningKey = new SymmetricSecurityKey(DeriveKeyBytes(o.Key)), + // Optional hardening: stricter expiry validation + ClockSkew = TimeSpan.Zero }; }); +// Shared key derivation helper to prevent duplication +static byte[] DeriveKeyBytes(string key) => + key.StartsWith("base64:", StringComparison.Ordinal) + ? Convert.FromBase64String(key["base64:".Length..]) + : Encoding.UTF8.GetBytes(key); + builder.Services.AddAuthorizationBuilder() .SetFallbackPolicy(new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build()); builder.Services.AddControllers(); +builder.Services.AddProblemDetails(); +builder.Services.AddHealthChecks(); builder.Services.AddSwaggerGen(c => { @@ -86,6 +114,13 @@ Array.Empty() } }); + + var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = System.IO.Path.Combine(AppContext.BaseDirectory, xmlFile); + if (System.IO.File.Exists(xmlPath)) + { + c.IncludeXmlComments(xmlPath); + } }); var promtailUrl = builder.Configuration["LOKI_PUSH_URL"] ?? "http://localhost:3101/loki/api/v1/push"; @@ -134,6 +169,29 @@ var app = builder.Build(); +// Global exception handling with ProblemDetails - must be first middleware +app.UseExceptionHandler(errorApp => +{ + errorApp.Run(async context => + { + var exceptionHandler = context.Features.Get(); + if (exceptionHandler is not null) + { + var problemDetails = new ProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Title = "An unexpected error occurred.", + Detail = app.Environment.IsDevelopment() ? exceptionHandler.Error.Message : null, + Instance = context.Request.Path + }; + + context.Response.StatusCode = problemDetails.Status.Value; + context.Response.ContentType = "application/problem+json"; + await context.Response.WriteAsJsonAsync(problemDetails); + } + }); +}); + app.UseCors(WebsiteClientOrigin); if (app.Environment.IsDevelopment()) @@ -147,4 +205,5 @@ app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); +app.MapHealthChecks("/health").AllowAnonymous(); app.Run();