diff --git a/Tiku.Api/Controllers/AuthController.cs b/Tiku.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..244ce09 --- /dev/null +++ b/Tiku.Api/Controllers/AuthController.cs @@ -0,0 +1,92 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Application.Auth; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Route("api/auth")] +public sealed class AuthController(IAuthService authService) : ControllerBase +{ + [AllowAnonymous] + [HttpPost("login/password")] + public async Task> LoginWithPassword( + PasswordLoginHttpRequest request, + CancellationToken cancellationToken) + { + var result = await authService.LoginWithPasswordAsync( + new PasswordLoginRequest( + request.TenantId, + request.Phone, + request.Password, + GetIpAddress(), + Request.Headers.UserAgent.ToString()), + cancellationToken); + + return Ok(result); + } + + [AllowAnonymous] + [HttpPost("login/sms")] + public async Task> LoginWithSms( + SmsLoginHttpRequest request, + CancellationToken cancellationToken) + { + var result = await authService.LoginWithSmsAsync( + new SmsLoginRequest( + request.TenantId, + request.Phone, + request.Code, + GetIpAddress(), + Request.Headers.UserAgent.ToString()), + cancellationToken); + + return Ok(result); + } + + [AllowAnonymous] + [HttpPost("refresh")] + public async Task> Refresh( + RefreshHttpRequest request, + CancellationToken cancellationToken) + { + var result = await authService.RefreshAsync( + new RefreshSessionRequest( + request.RefreshToken, + GetIpAddress(), + Request.Headers.UserAgent.ToString()), + cancellationToken); + + return Ok(result); + } + + [AllowAnonymous] + [HttpPost("logout")] + public async Task Logout( + RefreshHttpRequest request, + CancellationToken cancellationToken) + { + await authService.LogoutAsync( + new LogoutSessionRequest(request.RefreshToken), + cancellationToken); + + return NoContent(); + } + + private string? GetIpAddress() + { + return HttpContext.Connection.RemoteIpAddress?.ToString(); + } +} + +public sealed record PasswordLoginHttpRequest( + Guid TenantId, + string Phone, + string Password); + +public sealed record SmsLoginHttpRequest( + Guid TenantId, + string Phone, + string Code); + +public sealed record RefreshHttpRequest(string RefreshToken); diff --git a/Tiku.Api/Controllers/MeController.cs b/Tiku.Api/Controllers/MeController.cs new file mode 100644 index 0000000..4193797 --- /dev/null +++ b/Tiku.Api/Controllers/MeController.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Security; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Authorize(Policy = TikuPolicies.AuthenticatedUser)] +[Route("api/me")] +public sealed class MeController( + ICurrentUser currentUser, + TikuDbContext dbContext) : ControllerBase +{ + [HttpGet] + public async Task> Get(CancellationToken cancellationToken) + { + if (currentUser.UserId is null) + { + return Unauthorized(); + } + + var user = await dbContext.Users.FindAsync([currentUser.UserId.Value], cancellationToken); + if (user is null) + { + return Unauthorized(); + } + + var memberships = await dbContext.TenantMemberships + .Where(membership => + membership.UserId == user.Id && + membership.Status == MembershipStatus.Active) + .Join( + dbContext.Tenants, + membership => membership.TenantId, + tenant => tenant.Id, + (membership, tenant) => new TenantMembershipResponse( + tenant.Id, + tenant.Name, + tenant.Slug, + membership.Role, + membership.Status)) + .ToArrayAsync(cancellationToken); + + return Ok(new MeResponse( + user.Id, + user.Phone, + user.Email, + user.Name, + memberships)); + } +} + +public sealed record MeResponse( + Guid UserId, + string? Phone, + string? Email, + string? Name, + IReadOnlyCollection Tenants); + +public sealed record TenantMembershipResponse( + Guid TenantId, + string TenantName, + string TenantSlug, + TenantRole Role, + MembershipStatus Status); diff --git a/Tiku.Api/Controllers/TenantsController.cs b/Tiku.Api/Controllers/TenantsController.cs new file mode 100644 index 0000000..2ea0ed1 --- /dev/null +++ b/Tiku.Api/Controllers/TenantsController.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using Tiku.Application.Auth; +using Tiku.Application.Security; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Authorize(Policy = TikuPolicies.CurrentTenantMember)] +[Route("api/tenants")] +public sealed class TenantsController( + ICurrentUser currentUser, + ICurrentTenant currentTenant, + TikuDbContext dbContext) : ControllerBase +{ + [HttpGet("current")] + public async Task> GetCurrent(CancellationToken cancellationToken) + { + if (currentUser.UserId is null || currentTenant.TenantId is null) + { + throw new TenantAccessDeniedException(); + } + + var result = await dbContext.TenantMemberships + .Where(membership => + membership.UserId == currentUser.UserId.Value && + membership.TenantId == currentTenant.TenantId.Value && + membership.Status == MembershipStatus.Active) + .Join( + dbContext.Tenants, + membership => membership.TenantId, + tenant => tenant.Id, + (membership, tenant) => new CurrentTenantResponse( + tenant.Id, + tenant.Name, + tenant.Slug, + tenant.Status, + membership.Role, + membership.Permissions)) + .SingleOrDefaultAsync(cancellationToken); + + if (result is null) + { + throw new TenantAccessDeniedException(); + } + + return Ok(result); + } +} + +public sealed record CurrentTenantResponse( + Guid TenantId, + string TenantName, + string TenantSlug, + TenantStatus Status, + TenantRole Role, + JsonElement Permissions); diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 4ecd9c6..c7cce9f 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Tiku.Application.Auth; namespace Tiku.Api.Middleware; @@ -15,6 +16,12 @@ public sealed class ExceptionHandlingMiddleware( } catch (Exception exception) { + if (exception is AuthException authException) + { + await WriteAuthProblemAsync(context, authException); + return; + } + logger.LogError(exception, "Unhandled API exception"); var problem = new ProblemDetails @@ -30,4 +37,26 @@ public sealed class ExceptionHandlingMiddleware( await context.Response.WriteAsJsonAsync(problem); } } + + private static async Task WriteAuthProblemAsync(HttpContext context, AuthException exception) + { + var status = exception.Code switch + { + "tenant_access_denied" => StatusCodes.Status403Forbidden, + "sms_rate_limited" => StatusCodes.Status429TooManyRequests, + "session_revoked" => StatusCodes.Status401Unauthorized, + _ => StatusCodes.Status401Unauthorized + }; + var problem = new ProblemDetails + { + Title = exception.Message, + Status = status, + Instance = context.Request.Path + }; + + problem.Extensions["code"] = exception.Code; + problem.Extensions["traceId"] = context.TraceIdentifier; + context.Response.StatusCode = status; + await context.Response.WriteAsJsonAsync(problem); + } } diff --git a/Tiku.Api/Program.cs b/Tiku.Api/Program.cs index c61e770..fbf39e3 100644 --- a/Tiku.Api/Program.cs +++ b/Tiku.Api/Program.cs @@ -1,16 +1,23 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Scalar.AspNetCore; using System.Text; +using System.Text.Json.Serialization; using Tiku.Api.Middleware; using Tiku.Api.Security; using Tiku.Application; using Tiku.Application.Security; using Tiku.Infrastructure; +using Tiku.Infrastructure.Persistence; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddControllers(); +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); builder.Services.AddOpenApi(); builder.Services.AddProblemDetails(); builder.Services.AddApplication(); @@ -42,6 +49,36 @@ builder.Services 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(); + 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 => diff --git a/Tiku.Api/appsettings.json b/Tiku.Api/appsettings.json index d56220d..cfb8076 100644 --- a/Tiku.Api/appsettings.json +++ b/Tiku.Api/appsettings.json @@ -11,7 +11,8 @@ "Audience": "tiku-api", "SigningKey": "development-only-tiku-signing-key-change-before-production", "AccessTokenMinutes": 30, - "RefreshTokenDays": 30 + "RefreshTokenDays": 30, + "ValidateSessions": true } }, "AllowedHosts": "*" diff --git a/Tiku.Application/Security/JwtOptions.cs b/Tiku.Application/Security/JwtOptions.cs index 1008043..85e528c 100644 --- a/Tiku.Application/Security/JwtOptions.cs +++ b/Tiku.Application/Security/JwtOptions.cs @@ -7,4 +7,5 @@ public sealed class JwtOptions 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 bool ValidateSessions { get; set; } = true; } diff --git a/Tiku.IntegrationTests/Api/ApiTestFactory.cs b/Tiku.IntegrationTests/Api/ApiTestFactory.cs new file mode 100644 index 0000000..89b5f6d --- /dev/null +++ b/Tiku.IntegrationTests/Api/ApiTestFactory.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class ApiTestFactory : WebApplicationFactory +{ + private readonly string databaseName = Guid.NewGuid().ToString(); + + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + foreach (var descriptor in services + .Where(descriptor => + descriptor.ServiceType == typeof(NpgsqlDataSource) || + descriptor.ServiceType == typeof(DbContextOptions) || + descriptor.ServiceType.FullName?.Contains(nameof(TikuDbContext), StringComparison.Ordinal) == true) + .ToArray()) + { + services.Remove(descriptor); + } + + services.AddDbContext(options => + options.UseInMemoryDatabase(databaseName)); + }); + } + + public async Task SeedAsync(params object[] entities) + { + using var scope = Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.AddRange(entities); + await dbContext.SaveChangesAsync(); + } + + public async Task SeedActiveSessionAsync( + Guid userId, + Guid? tenantId = null, + string tokenHash = "integration-test-token-hash") + { + var resolvedTenantId = tenantId ?? Guid.NewGuid(); + await SeedAsync( + new Tenant + { + Id = resolvedTenantId, + Slug = resolvedTenantId.ToString("N"), + Name = "Test Tenant" + }, + new User + { + Id = userId, + Phone = "13800000000" + }, + new AuthSession + { + Id = Guid.NewGuid(), + TenantId = resolvedTenantId, + UserId = userId, + TokenHash = tokenHash, + Provider = "test", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }); + + using var scope = Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + return await dbContext.AuthSessions + .Where(session => session.UserId == userId) + .Select(session => session.Id) + .SingleAsync(); + } +} diff --git a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs new file mode 100644 index 0000000..9171709 --- /dev/null +++ b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs @@ -0,0 +1,166 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Api.Controllers; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class AuthEndpointTests +{ + [Fact] + public async Task Password_login_can_access_current_user_and_tenant() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedLoginUserAsync(factory); + using var client = factory.CreateClient(); + + var loginResponse = await client.PostAsJsonAsync( + "/api/auth/login/password", + new PasswordLoginHttpRequest(seed.TenantId, seed.Phone, "passw0rd!")); + var loginJson = await ReadJsonAsync(loginResponse); + var accessToken = loginJson.RootElement + .GetProperty("tokens") + .GetProperty("accessToken") + .GetString(); + + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + var meResponse = await client.GetAsync("/api/me"); + var tenantResponse = await client.GetAsync("/api/tenants/current"); + + Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, tenantResponse.StatusCode); + Assert.Contains(seed.UserId.ToString(), await meResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Contains(seed.TenantId.ToString(), await tenantResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Sms_login_can_access_current_user() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedLoginUserAsync(factory); + await SeedSmsCodeAsync(factory, seed.TenantId, seed.Phone, "123456"); + using var client = factory.CreateClient(); + + var loginResponse = await client.PostAsJsonAsync( + "/api/auth/login/sms", + new SmsLoginHttpRequest(seed.TenantId, seed.Phone, "123456")); + var loginJson = await ReadJsonAsync(loginResponse); + var accessToken = loginJson.RootElement + .GetProperty("tokens") + .GetProperty("accessToken") + .GetString(); + + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + var meResponse = await client.GetAsync("/api/me"); + + Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); + } + + [Fact] + public async Task Logout_revokes_access_and_refresh_tokens() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedLoginUserAsync(factory); + using var client = factory.CreateClient(); + var loginResponse = await client.PostAsJsonAsync( + "/api/auth/login/password", + new PasswordLoginHttpRequest(seed.TenantId, seed.Phone, "passw0rd!")); + var loginJson = await ReadJsonAsync(loginResponse); + var tokens = loginJson.RootElement.GetProperty("tokens"); + var accessToken = tokens.GetProperty("accessToken").GetString(); + var refreshToken = tokens.GetProperty("refreshToken").GetString(); + + var logoutResponse = await client.PostAsJsonAsync( + "/api/auth/logout", + new RefreshHttpRequest(refreshToken!)); + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + var meResponse = await client.GetAsync("/api/me"); + var refreshResponse = await client.PostAsJsonAsync( + "/api/auth/refresh", + new RefreshHttpRequest(refreshToken!)); + + Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, meResponse.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, refreshResponse.StatusCode); + } + + private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync( + ApiTestFactory factory) + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var phone = "13800000000"; + var passwordHash = new PasswordHasher().Hash("passw0rd!"); + + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Test Tenant" + }, + new User + { + Id = userId, + Phone = phone, + Name = "Test User" + }, + new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.TenantAdmin, + Status = MembershipStatus.Active + }, + new UserIdentity + { + UserId = userId, + Provider = "password", + ProviderSubject = phone, + Phone = phone, + SecretPayload = CreateSecretPayload(passwordHash) + }); + + return (tenantId, userId, phone); + } + + private static async Task SeedSmsCodeAsync( + ApiTestFactory factory, + Guid tenantId, + string phone, + string code) + { + using var scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.SmsVerificationCodes.Add(new SmsVerificationCode + { + TenantId = tenantId, + Phone = phone, + Purpose = SmsPurpose.Login, + CodeHash = SmsCodeHashing.Hash(tenantId, phone, SmsPurpose.Login, code), + Status = SmsVerificationStatus.Sent, + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) + }); + await dbContext.SaveChangesAsync(); + } + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + var stream = await response.Content.ReadAsStreamAsync(); + return await JsonDocument.ParseAsync(stream); + } + + private static JsonElement CreateSecretPayload(string passwordHash) + { + using var document = JsonDocument.Parse( + $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); + return document.RootElement.Clone(); + } +} diff --git a/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs b/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs index e136bf1..a03b703 100644 --- a/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs +++ b/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs @@ -2,7 +2,6 @@ using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Security.Claims; using System.Text; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.IdentityModel.Tokens; using Tiku.Application.Security; using Tiku.Domain.Tenancy; @@ -33,11 +32,14 @@ public sealed class SecurityFoundationTests public async Task Tenant_admin_policy_returns_forbidden_for_non_admin_member() { await using var factory = CreateFactory(); + var userId = Guid.NewGuid(); + var sessionId = await factory.SeedActiveSessionAsync(userId); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new( "Bearer", CreateToken([ - new Claim(TikuClaimTypes.UserId, Guid.NewGuid().ToString()), + new Claim(TikuClaimTypes.UserId, userId.ToString()), + new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), new Claim(TikuClaimTypes.TenantId, Guid.NewGuid().ToString()), new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString()) ])); @@ -52,11 +54,13 @@ public sealed class SecurityFoundationTests { var userId = Guid.NewGuid(); await using var factory = CreateFactory(); + var sessionId = await factory.SeedActiveSessionAsync(userId); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new( "Bearer", CreateToken([ - new Claim(TikuClaimTypes.UserId, userId.ToString()) + new Claim(TikuClaimTypes.UserId, userId.ToString()), + new Claim(TikuClaimTypes.SessionId, sessionId.ToString()) ])); var response = await client.GetAsync("/api/_security/authenticated"); @@ -67,9 +71,9 @@ public sealed class SecurityFoundationTests Assert.Contains("true", body, StringComparison.OrdinalIgnoreCase); } - private static WebApplicationFactory CreateFactory() + private static ApiTestFactory CreateFactory() { - return new WebApplicationFactory(); + return new ApiTestFactory(); } private static string CreateToken(IEnumerable claims) diff --git a/Tiku.IntegrationTests/Tiku.IntegrationTests.csproj b/Tiku.IntegrationTests/Tiku.IntegrationTests.csproj index 9dd9ad8..8591823 100644 --- a/Tiku.IntegrationTests/Tiku.IntegrationTests.csproj +++ b/Tiku.IntegrationTests/Tiku.IntegrationTests.csproj @@ -23,6 +23,7 @@ all +