diff --git a/Directory.Packages.props b/Directory.Packages.props index 04a58a5..0a40a8e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,8 @@ all + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -24,10 +26,11 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all - \ No newline at end of file + diff --git a/Tiku.Api/Controllers/SecurityDiagnosticsController.cs b/Tiku.Api/Controllers/SecurityDiagnosticsController.cs new file mode 100644 index 0000000..5bca13f --- /dev/null +++ b/Tiku.Api/Controllers/SecurityDiagnosticsController.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +[ApiController] +[ApiExplorerSettings(IgnoreApi = true)] +[Route("api/_security")] +public sealed class SecurityDiagnosticsController( + ICurrentUser currentUser, + ICurrentTenant currentTenant) : ControllerBase +{ + [Authorize(Policy = TikuPolicies.AuthenticatedUser)] + [HttpGet("authenticated")] + public ActionResult Authenticated() + { + return Ok(new + { + currentUser.UserId, + currentUser.IsAuthenticated + }); + } + + [Authorize(Policy = TikuPolicies.CurrentTenantMember)] + [HttpGet("tenant-member")] + public ActionResult TenantMember() + { + return Ok(new + { + currentTenant.TenantId, + currentTenant.Role + }); + } + + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [HttpGet("tenant-admin")] + public ActionResult TenantAdmin() + { + return Ok(new + { + currentTenant.TenantId, + currentTenant.Role + }); + } +} diff --git a/Tiku.Api/Middleware/CurrentPrincipalMiddleware.cs b/Tiku.Api/Middleware/CurrentPrincipalMiddleware.cs new file mode 100644 index 0000000..a1bb3b9 --- /dev/null +++ b/Tiku.Api/Middleware/CurrentPrincipalMiddleware.cs @@ -0,0 +1,16 @@ +using Tiku.Application.Security; + +namespace Tiku.Api.Middleware; + +public sealed class CurrentPrincipalMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync( + HttpContext context, + ICurrentUser currentUser, + ICurrentTenant currentTenant) + { + currentUser.Load(context.User); + currentTenant.Load(context.User); + await next(context); + } +} diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..4ecd9c6 --- /dev/null +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Tiku.Api.Middleware; + +public sealed class ExceptionHandlingMiddleware( + RequestDelegate next, + ILogger logger, + IHostEnvironment environment) +{ + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (Exception exception) + { + logger.LogError(exception, "Unhandled API exception"); + + var problem = new ProblemDetails + { + Title = "An unexpected error occurred.", + Status = StatusCodes.Status500InternalServerError, + Detail = environment.IsDevelopment() ? exception.Message : null, + Instance = context.Request.Path + }; + + problem.Extensions["traceId"] = context.TraceIdentifier; + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsJsonAsync(problem); + } + } +} diff --git a/Tiku.Api/Options/JwtOptions.cs b/Tiku.Api/Options/JwtOptions.cs new file mode 100644 index 0000000..7fb7e70 --- /dev/null +++ b/Tiku.Api/Options/JwtOptions.cs @@ -0,0 +1,19 @@ +using Microsoft.IdentityModel.Tokens; +using System.Text; + +namespace Tiku.Api.Options; + +public sealed class JwtOptions +{ + public string Issuer { get; set; } = "tiku-backend"; + public string Audience { get; set; } = "tiku-api"; + public string SigningKey { get; set; } = "development-only-tiku-signing-key-change-before-production"; + public int AccessTokenMinutes { get; set; } = 30; + public int RefreshTokenDays { get; set; } = 30; + + public SymmetricSecurityKey CreateSecurityKey() + { + ArgumentException.ThrowIfNullOrWhiteSpace(SigningKey); + return new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey)); + } +} diff --git a/Tiku.Api/Program.cs b/Tiku.Api/Program.cs index 8000192..a8727a9 100644 --- a/Tiku.Api/Program.cs +++ b/Tiku.Api/Program.cs @@ -1,40 +1,86 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Scalar.AspNetCore; +using Tiku.Api.Middleware; +using Tiku.Api.Options; +using Tiku.Api.Security; +using Tiku.Application; +using Tiku.Application.Security; +using Tiku.Infrastructure; + var builder = WebApplication.CreateBuilder(args); -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddControllers(); builder.Services.AddOpenApi(); +builder.Services.AddProblemDetails(); +builder.Services.AddApplication(); + +var connectionString = + builder.Configuration.GetConnectionString("Database") ?? + builder.Configuration["DATABASE_URL"] ?? + "Host=localhost;Database=tiku;Username=postgres"; + +builder.Services.AddInfrastructure(connectionString); + +builder.Services.Configure(builder.Configuration.GetSection("Security:Jwt")); +var jwtOptions = builder.Configuration + .GetSection("Security:Jwt") + .Get() ?? 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 = jwtOptions.CreateSecurityKey(), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1) + }; + }); + +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(); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.MapOpenApi(); + app.MapScalarApiReference(); } +app.UseMiddleware(); app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseMiddleware(); +app.UseAuthorization(); -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithName("GetWeatherForecast"); +app.MapControllers(); app.Run(); +public partial class Program; + record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) { public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); diff --git a/Tiku.Api/Security/TenantRoleAuthorization.cs b/Tiku.Api/Security/TenantRoleAuthorization.cs new file mode 100644 index 0000000..a524037 --- /dev/null +++ b/Tiku.Api/Security/TenantRoleAuthorization.cs @@ -0,0 +1,30 @@ +using System.Security.Claims; +using Tiku.Application.Security; +using Tiku.Domain.Tenancy; + +namespace Tiku.Api.Security; + +internal static class TenantRoleAuthorization +{ + private static readonly HashSet AdminRoles = new(StringComparer.Ordinal) + { + TenantRole.PlatformAdmin.ToString(), + TenantRole.TenantOwner.ToString(), + TenantRole.TenantAdmin.ToString() + }; + + public static bool IsTenantMember(ClaimsPrincipal principal) + { + return principal.Identity?.IsAuthenticated == true && + principal.HasClaim(claim => claim.Type == TikuClaimTypes.TenantId); + } + + public static bool IsTenantAdmin(ClaimsPrincipal principal) + { + return IsTenantMember(principal) && + principal.Claims + .Where(claim => claim.Type == TikuClaimTypes.TenantRole) + .Select(claim => claim.Value) + .Any(role => AdminRoles.Contains(role)); + } +} diff --git a/Tiku.Api/Tiku.Api.csproj b/Tiku.Api/Tiku.Api.csproj index 81ecc70..5b9ee05 100644 --- a/Tiku.Api/Tiku.Api.csproj +++ b/Tiku.Api/Tiku.Api.csproj @@ -12,9 +12,11 @@ + + diff --git a/Tiku.Api/appsettings.json b/Tiku.Api/appsettings.json index 10f68b8..d56220d 100644 --- a/Tiku.Api/appsettings.json +++ b/Tiku.Api/appsettings.json @@ -5,5 +5,14 @@ "Microsoft.AspNetCore": "Warning" } }, + "Security": { + "Jwt": { + "Issuer": "tiku-backend", + "Audience": "tiku-api", + "SigningKey": "development-only-tiku-signing-key-change-before-production", + "AccessTokenMinutes": 30, + "RefreshTokenDays": 30 + } + }, "AllowedHosts": "*" } diff --git a/Tiku.Application/DependencyInjection.cs b/Tiku.Application/DependencyInjection.cs new file mode 100644 index 0000000..cabe458 --- /dev/null +++ b/Tiku.Application/DependencyInjection.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Security; + +namespace Tiku.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/Tiku.Application/Security/ClaimsPrincipalExtensions.cs b/Tiku.Application/Security/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..0ca4ba9 --- /dev/null +++ b/Tiku.Application/Security/ClaimsPrincipalExtensions.cs @@ -0,0 +1,17 @@ +using System.Security.Claims; + +namespace Tiku.Application.Security; + +internal static class ClaimsPrincipalExtensions +{ + public static string? FindValue(this ClaimsPrincipal principal, string claimType) + { + return principal.FindFirst(claimType)?.Value; + } + + public static Guid? FindGuid(this ClaimsPrincipal principal, string claimType) + { + var value = principal.FindValue(claimType); + return Guid.TryParse(value, out var guid) ? guid : null; + } +} diff --git a/Tiku.Application/Security/CurrentTenant.cs b/Tiku.Application/Security/CurrentTenant.cs new file mode 100644 index 0000000..fd5547f --- /dev/null +++ b/Tiku.Application/Security/CurrentTenant.cs @@ -0,0 +1,16 @@ +using System.Security.Claims; + +namespace Tiku.Application.Security; + +public sealed class CurrentTenant : ICurrentTenant +{ + public Guid? TenantId { get; private set; } + public string? Role { get; private set; } + public bool IsResolved => TenantId.HasValue; + + public void Load(ClaimsPrincipal principal) + { + TenantId = principal.FindGuid(TikuClaimTypes.TenantId); + Role = principal.FindValue(TikuClaimTypes.TenantRole); + } +} diff --git a/Tiku.Application/Security/CurrentUser.cs b/Tiku.Application/Security/CurrentUser.cs new file mode 100644 index 0000000..30b9432 --- /dev/null +++ b/Tiku.Application/Security/CurrentUser.cs @@ -0,0 +1,21 @@ +using System.Security.Claims; + +namespace Tiku.Application.Security; + +public sealed class CurrentUser : ICurrentUser +{ + public Guid? UserId { get; private set; } + public Guid? SessionId { get; private set; } + public string? Phone { get; private set; } + public string? Email { get; private set; } + public bool IsAuthenticated { get; private set; } + + public void Load(ClaimsPrincipal principal) + { + IsAuthenticated = principal.Identity?.IsAuthenticated == true; + UserId = principal.FindGuid(TikuClaimTypes.UserId); + SessionId = principal.FindGuid(TikuClaimTypes.SessionId); + Phone = principal.FindValue(TikuClaimTypes.Phone); + Email = principal.FindValue(TikuClaimTypes.Email); + } +} diff --git a/Tiku.Application/Security/ICurrentTenant.cs b/Tiku.Application/Security/ICurrentTenant.cs new file mode 100644 index 0000000..c5fafe4 --- /dev/null +++ b/Tiku.Application/Security/ICurrentTenant.cs @@ -0,0 +1,11 @@ +using System.Security.Claims; + +namespace Tiku.Application.Security; + +public interface ICurrentTenant +{ + Guid? TenantId { get; } + string? Role { get; } + bool IsResolved { get; } + void Load(ClaimsPrincipal principal); +} diff --git a/Tiku.Application/Security/ICurrentUser.cs b/Tiku.Application/Security/ICurrentUser.cs new file mode 100644 index 0000000..3539862 --- /dev/null +++ b/Tiku.Application/Security/ICurrentUser.cs @@ -0,0 +1,13 @@ +using System.Security.Claims; + +namespace Tiku.Application.Security; + +public interface ICurrentUser +{ + Guid? UserId { get; } + Guid? SessionId { get; } + string? Phone { get; } + string? Email { get; } + bool IsAuthenticated { get; } + void Load(ClaimsPrincipal principal); +} diff --git a/Tiku.Application/Security/TikuClaimTypes.cs b/Tiku.Application/Security/TikuClaimTypes.cs new file mode 100644 index 0000000..59df2b6 --- /dev/null +++ b/Tiku.Application/Security/TikuClaimTypes.cs @@ -0,0 +1,13 @@ +using System.Security.Claims; + +namespace Tiku.Application.Security; + +public static class TikuClaimTypes +{ + public const string UserId = "tiku:user_id"; + public const string TenantId = "tiku:tenant_id"; + public const string SessionId = "tiku:session_id"; + public const string TenantRole = "tiku:tenant_role"; + public const string Phone = ClaimTypes.MobilePhone; + public const string Email = ClaimTypes.Email; +} diff --git a/Tiku.Application/Security/TikuPolicies.cs b/Tiku.Application/Security/TikuPolicies.cs new file mode 100644 index 0000000..2ff5019 --- /dev/null +++ b/Tiku.Application/Security/TikuPolicies.cs @@ -0,0 +1,8 @@ +namespace Tiku.Application.Security; + +public static class TikuPolicies +{ + public const string AuthenticatedUser = "authenticated_user"; + public const string CurrentTenantMember = "current_tenant_member"; + public const string TenantAdmin = "tenant_admin"; +} diff --git a/Tiku.Application/Tiku.Application.csproj b/Tiku.Application/Tiku.Application.csproj index ca1e5e9..cceeb12 100644 --- a/Tiku.Application/Tiku.Application.csproj +++ b/Tiku.Application/Tiku.Application.csproj @@ -4,6 +4,10 @@ + + + + net10.0 enable diff --git a/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs b/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs new file mode 100644 index 0000000..9eabe98 --- /dev/null +++ b/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs @@ -0,0 +1,90 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Security.Claims; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.IdentityModel.Tokens; +using Tiku.Api.Options; +using Tiku.Application.Security; +using Tiku.Domain.Tenancy; + +namespace Tiku.IntegrationTests.Api; + +public sealed class SecurityFoundationTests +{ + private static readonly JwtOptions JwtOptions = new() + { + Issuer = "tiku-backend", + Audience = "tiku-api", + SigningKey = "development-only-tiku-signing-key-change-before-production" + }; + + [Fact] + public async Task Authenticated_policy_returns_unauthorized_without_token() + { + await using var factory = CreateFactory(); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/_security/authenticated"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Tenant_admin_policy_returns_forbidden_for_non_admin_member() + { + await using var factory = CreateFactory(); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new( + "Bearer", + CreateToken([ + new Claim(TikuClaimTypes.UserId, Guid.NewGuid().ToString()), + new Claim(TikuClaimTypes.TenantId, Guid.NewGuid().ToString()), + new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString()) + ])); + + var response = await client.GetAsync("/api/_security/tenant-admin"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Controller_pipeline_loads_authenticated_current_user() + { + var userId = Guid.NewGuid(); + await using var factory = CreateFactory(); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new( + "Bearer", + CreateToken([ + new Claim(TikuClaimTypes.UserId, userId.ToString()) + ])); + + var response = await client.GetAsync("/api/_security/authenticated"); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains(userId.ToString(), body, StringComparison.OrdinalIgnoreCase); + Assert.Contains("true", body, StringComparison.OrdinalIgnoreCase); + } + + private static WebApplicationFactory CreateFactory() + { + return new WebApplicationFactory(); + } + + private static string CreateToken(IEnumerable claims) + { + var credentials = new SigningCredentials( + JwtOptions.CreateSecurityKey(), + SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + JwtOptions.Issuer, + JwtOptions.Audience, + claims, + expires: DateTime.UtcNow.AddMinutes(5), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/Tiku.IntegrationTests/Tiku.IntegrationTests.csproj b/Tiku.IntegrationTests/Tiku.IntegrationTests.csproj index 8bba218..9dd9ad8 100644 --- a/Tiku.IntegrationTests/Tiku.IntegrationTests.csproj +++ b/Tiku.IntegrationTests/Tiku.IntegrationTests.csproj @@ -22,6 +22,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + @@ -30,4 +31,4 @@ - \ No newline at end of file +