From a8adb96c9e919d1b850d75d4b71aa547e5f587b7 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 22 Oct 2026 12:01:30 +0200 Subject: [PATCH 01/33] chore(dotnet-tools.json): bump dotnet-ef cli tool --- server/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/dotnet-tools.json b/server/dotnet-tools.json index 5c1165b..39007e0 100644 --- a/server/dotnet-tools.json +++ b/server/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "10.0.12", + "version": "10.0.13", "commands": [ "dotnet-ef" ], From bdd4b6776c48c842de6e857b78aa4966e8cf3cd1 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 22 Oct 2026 13:42:19 +0200 Subject: [PATCH 02/33] feat: generate access and refresh token during user provisioning --- .../ProvisionUser/IUserSessionService.cs | 8 + .../Commands/ProvisionUser/LoginResponse.cs | 3 + .../ProvisionUserCommandHandler.cs | 35 +- .../Users/Commands/ProvisionUser/UserDto.cs | 5 - .../Application/Mappings/MappingsProfile.cs | 5 +- server/Application/packages.lock.json | 131 +----- server/Directory.Packages.props | 4 +- server/Domain/packages.lock.json | 25 +- server/Infrastructure/AppDbContext.cs | 3 + .../Auth/JwtUserSessionService.cs | 62 +++ server/Infrastructure/Auth/RefreshToken.cs | 13 + .../Extensions/InfrastructureConfiguration.cs | 1 + server/Infrastructure/Infrastructure.csproj | 1 + server/Infrastructure/packages.lock.json | 318 +-------------- .../Controllers/AuthController.cs | 2 +- .../Extensions/PresentationConfiguration.cs | 36 +- server/Presentation/Presentation.csproj | 1 + server/Presentation/Program.cs | 2 +- server/Presentation/packages.lock.json | 373 +----------------- .../ProvisionUserCommandHandlerTests.cs | 17 +- server/PutWiki.UnitTests/packages.lock.json | 365 +---------------- 21 files changed, 166 insertions(+), 1244 deletions(-) create mode 100644 server/Application/Features/Users/Commands/ProvisionUser/IUserSessionService.cs create mode 100644 server/Application/Features/Users/Commands/ProvisionUser/LoginResponse.cs delete mode 100644 server/Application/Features/Users/Commands/ProvisionUser/UserDto.cs create mode 100644 server/Infrastructure/Auth/JwtUserSessionService.cs create mode 100644 server/Infrastructure/Auth/RefreshToken.cs diff --git a/server/Application/Features/Users/Commands/ProvisionUser/IUserSessionService.cs b/server/Application/Features/Users/Commands/ProvisionUser/IUserSessionService.cs new file mode 100644 index 0000000..a9da899 --- /dev/null +++ b/server/Application/Features/Users/Commands/ProvisionUser/IUserSessionService.cs @@ -0,0 +1,8 @@ +using System; + +namespace Application.Features.Users.Commands.ProvisionUser; + +public interface IUserSessionService +{ + (string AccessToken, string RefreshToken) CreateSession(Guid userId); +} \ No newline at end of file diff --git a/server/Application/Features/Users/Commands/ProvisionUser/LoginResponse.cs b/server/Application/Features/Users/Commands/ProvisionUser/LoginResponse.cs new file mode 100644 index 0000000..659d49d --- /dev/null +++ b/server/Application/Features/Users/Commands/ProvisionUser/LoginResponse.cs @@ -0,0 +1,3 @@ +namespace Application.Features.Users.Commands.ProvisionUser; + +public sealed record LoginResponse(string AccessToken, string RefreshToken); \ No newline at end of file diff --git a/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs b/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs index 8d50444..c216736 100644 --- a/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs +++ b/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs @@ -6,8 +6,6 @@ using Application.Errors; using Application.Interfaces; -using AutoMapper; - using Domain.Users; using FluentResults; @@ -22,11 +20,11 @@ public partial class ProvisionUserCommandHandler( IUserRepository userRepository, IUnitOfWork unitOfWork, ILogger logger, - IMapper mapper, - TimeProvider timeProvider) + TimeProvider timeProvider, + IUserSessionService userSessionService) { - public async Task> ExecuteAsync(ProvisionUserCommand cmd, + public async Task> ExecuteAsync(ProvisionUserCommand cmd, CancellationToken ct = default) { var usosResult = await usosOAuthService.HandleCallbackAndGetUserAsync(cmd.OauthToken, cmd.OauthVerifier, ct); @@ -44,24 +42,31 @@ public async Task> ExecuteAsync(ProvisionUserCommand cmd, var rawUsosId = usosResult.Value.Id; var hashedId = hasher.Hash(rawUsosId); - var existingUser = await userRepository.GetByHashedUsosIdAsync(hashedId, ct); - if (existingUser != null) + var user = await userRepository.GetByHashedUsosIdAsync(hashedId, ct); + if (user is null) + { + user = User.Create(hashedId, timeProvider.GetUtcNow()); + userRepository.Add(user); + LogProvisionedNewUser(user.Id); + } + else { - return Result.Ok(mapper.Map(existingUser)); + LogUserLoggedIn(user.Id); } - var newUser = User.Create(hashedId, timeProvider.GetUtcNow()); - userRepository.Add(newUser); - await unitOfWork.SaveChangesAsync(ct); + var (accessToken, refreshToken) = userSessionService.CreateSession(user.Id); - LogProvisionedNewAnonymousUserId(newUser.Id); + await unitOfWork.SaveChangesAsync(ct); - return Result.Ok(mapper.Map(newUser)); + return Result.Ok(new LoginResponse(accessToken, refreshToken)); } [LoggerMessage(LogLevel.Warning, "Provisioning aborted: USOS authentication failed. Error: {error}")] partial void LogProvisioningAbortedUsosAuthenticationFailed(string error); - [LoggerMessage(LogLevel.Information, "Provisioned new anonymous user {userId}")] - partial void LogProvisionedNewAnonymousUserId(Guid userId); + [LoggerMessage(LogLevel.Information, "Provisioned new user with id: {userId}")] + partial void LogProvisionedNewUser(Guid userId); + + [LoggerMessage(LogLevel.Information, "User with id: {userId} has logged in")] + partial void LogUserLoggedIn(Guid userId); } \ No newline at end of file diff --git a/server/Application/Features/Users/Commands/ProvisionUser/UserDto.cs b/server/Application/Features/Users/Commands/ProvisionUser/UserDto.cs deleted file mode 100644 index 0e98e57..0000000 --- a/server/Application/Features/Users/Commands/ProvisionUser/UserDto.cs +++ /dev/null @@ -1,5 +0,0 @@ -using System; - -namespace Application.Features.Users.Commands.ProvisionUser; - -public record UserDto(string Id, string HashedUsosId, DateTimeOffset JoinedDate); \ No newline at end of file diff --git a/server/Application/Mappings/MappingsProfile.cs b/server/Application/Mappings/MappingsProfile.cs index afa2f20..5f9d0ef 100644 --- a/server/Application/Mappings/MappingsProfile.cs +++ b/server/Application/Mappings/MappingsProfile.cs @@ -8,8 +8,5 @@ namespace Application.Mappings; public class MappingsProfile : Profile { - public MappingsProfile() - { - CreateMap(); - } + public MappingsProfile() { } } \ No newline at end of file diff --git a/server/Application/packages.lock.json b/server/Application/packages.lock.json index b679def..4a91a8c 100644 --- a/server/Application/packages.lock.json +++ b/server/Application/packages.lock.json @@ -1,133 +1,6 @@ { - "version": 2, + "version": 1, "dependencies": { - "net10.0": { - "AutoMapper": { - "type": "Direct", - "requested": "[16.3.0, )", - "resolved": "16.3.0", - "contentHash": "wFTqA37e1I4AOEhTTYlxrqDE8BQ4OB3dqhlv2KwYmHv+a4tn7R1JW2n2bzXb8eTBhWVBSl7iTZ0VxEt0a57D4w==", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0", - "Microsoft.IdentityModel.JsonWebTokens": "8.14.0" - } - }, - "FluentResults": { - "type": "Direct", - "requested": "[4.0.0, )", - "resolved": "4.0.0", - "contentHash": "wKfdLvDnDVhXlSreU7u/NFNHH6EfoOhAloEID4ePAyk+ZliowKZJvX+aCL4hxhFc9Jj74t62amS4LyOYTjXGCg==", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "2.1.1" - } - }, - "FluentValidation.DependencyInjectionExtensions": { - "type": "Direct", - "requested": "[12.1.1, )", - "resolved": "12.1.1", - "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", - "dependencies": { - "FluentValidation": "12.1.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" - } - }, - "Moq": { - "type": "Direct", - "requested": "[4.21.0, )", - "resolved": "4.21.0", - "contentHash": "HUjrhhzEJ/AfbSBCnR8lPkpveVGpV/dNuOlnpfqBNTME93izly1CRV4N5zBySqz4m2yCRlHLPy/PnPzYokbwIg==", - "dependencies": { - "Castle.Core": "5.2.1" - } - }, - "SonarAnalyzer.CSharp": { - "type": "Direct", - "requested": "[10.35.0.4138, )", - "resolved": "10.35.0.4138", - "contentHash": "T5o/tuyPw9pHRkyWxW07TkSri4I4pLpqwH922JuhphDjHAM7BUHaGE67h/w+65evHsIar8mYB2Ey6OmOLj9QDw==" - }, - "Castle.Core": { - "type": "Transitive", - "resolved": "5.2.1", - "contentHash": "wHARzQA695jwwKreOzNsq54KiGqKP38tv8hi8e2FXDEC/sA6BtrX90tVPDkOfVu13PbEzr00TCV8coikl+D1Iw==", - "dependencies": { - "System.Diagnostics.EventLog": "6.0.0" - } - }, - "FluentValidation": { - "type": "Transitive", - "resolved": "12.1.1", - "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA==" - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" - } - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "inRnbpCS0nwO/RuoZIAqxQUuyjaknOOnCEZB55KSMMjRhl0RQDttSmLSGsUJN3RQ3ocf5NDLFd2mOQViHqMK5w==" - }, - "Microsoft.IdentityModel.Abstractions": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" - }, - "Microsoft.IdentityModel.JsonWebTokens": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "4jOpiA4THdtpLyMdAb24dtj7+6GmvhOhxf5XHLYWmPKF8ApEnApal1UnJsKO4HxUWRXDA6C4WQVfYyqsRhpNpQ==", - "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.14.0" - } - }, - "Microsoft.IdentityModel.Logging": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==", - "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.14.0" - } - }, - "Microsoft.IdentityModel.Tokens": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "lKIZiBiGd36k02TCdMHp1KlNWisyIvQxcYJvIkz7P4gSQ9zi8dgh6S5Grj8NNG7HWYIPfQymGyoZ6JB5d1Lo1g==", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "8.0.0", - "Microsoft.IdentityModel.Logging": "8.14.0" - } - }, - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" - }, - "domain": { - "type": "Project", - "dependencies": { - "FluentResults": "[4.0.0, )" - } - } - } + "net10.0": {} } } \ No newline at end of file diff --git a/server/Directory.Packages.props b/server/Directory.Packages.props index a7702d1..8ec85f6 100644 --- a/server/Directory.Packages.props +++ b/server/Directory.Packages.props @@ -10,8 +10,10 @@ - + + + diff --git a/server/Domain/packages.lock.json b/server/Domain/packages.lock.json index bea516f..4a91a8c 100644 --- a/server/Domain/packages.lock.json +++ b/server/Domain/packages.lock.json @@ -1,27 +1,6 @@ { - "version": 2, + "version": 1, "dependencies": { - "net10.0": { - "FluentResults": { - "type": "Direct", - "requested": "[4.0.0, )", - "resolved": "4.0.0", - "contentHash": "wKfdLvDnDVhXlSreU7u/NFNHH6EfoOhAloEID4ePAyk+ZliowKZJvX+aCL4hxhFc9Jj74t62amS4LyOYTjXGCg==", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "2.1.1" - } - }, - "SonarAnalyzer.CSharp": { - "type": "Direct", - "requested": "[10.35.0.4138, )", - "resolved": "10.35.0.4138", - "contentHash": "T5o/tuyPw9pHRkyWxW07TkSri4I4pLpqwH922JuhphDjHAM7BUHaGE67h/w+65evHsIar8mYB2Ey6OmOLj9QDw==" - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "2.1.1", - "contentHash": "XRzK7ZF+O6FzdfWrlFTi1Rgj2080ZDsd46vzOjadHUB0Cz5kOvDG8vI7caa5YFrsHQpcfn0DxtjS4E46N4FZsA==" - } - } + "net10.0": {} } } \ No newline at end of file diff --git a/server/Infrastructure/AppDbContext.cs b/server/Infrastructure/AppDbContext.cs index 9853fc1..a2e6354 100644 --- a/server/Infrastructure/AppDbContext.cs +++ b/server/Infrastructure/AppDbContext.cs @@ -3,6 +3,7 @@ using Domain.AcademicTeachers; using Domain.Users; +using Infrastructure.Auth; using Infrastructure.EntitiesConfiguration; using Microsoft.EntityFrameworkCore; @@ -13,6 +14,8 @@ public class AppDbContext(DbContextOptions options) : DbContext(op { public required DbSet Users { get; set; } public required DbSet AcademicTeachers { get; set; } + public required DbSet RefreshTokens { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/server/Infrastructure/Auth/JwtUserSessionService.cs b/server/Infrastructure/Auth/JwtUserSessionService.cs new file mode 100644 index 0000000..63fd971 --- /dev/null +++ b/server/Infrastructure/Auth/JwtUserSessionService.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; + +using Application.Features.Users.Commands.ProvisionUser; + +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Infrastructure.Auth; + +public sealed class JwtUserSessionService( + IConfiguration configuration, + AppDbContext dbContext, + TimeProvider timeProvider + ) : IUserSessionService +{ + public (string AccessToken, string RefreshToken) CreateSession(Guid userId) + { + var accessToken = GenerateAccessToken(userId); + + var refreshToken = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = userId, + Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)), + ExpiresAt = timeProvider.GetUtcNow().AddDays(7).DateTime, + CreatedAt = timeProvider.GetUtcNow().DateTime + }; + + dbContext.RefreshTokens.Add(refreshToken); + + return (accessToken, refreshToken.Token); + } + + private string GenerateAccessToken(Guid userId) + { + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + }; + + var key = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"]!)); + + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = timeProvider.GetUtcNow().AddMinutes(30).DateTime, + SigningCredentials = credentials, + Issuer = configuration["Jwt:Issuer"], + Audience = configuration["Jwt:Audience"] + }; + + return new JsonWebTokenHandler().CreateToken(tokenDescriptor); + } +} \ No newline at end of file diff --git a/server/Infrastructure/Auth/RefreshToken.cs b/server/Infrastructure/Auth/RefreshToken.cs new file mode 100644 index 0000000..6b5cd54 --- /dev/null +++ b/server/Infrastructure/Auth/RefreshToken.cs @@ -0,0 +1,13 @@ +using System; + +namespace Infrastructure.Auth; + +public sealed class RefreshToken +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string? Token { get; set; } + public DateTime ExpiresAt { get; set; } + public bool IsRevoked { get; set; } + public DateTime CreatedAt { get; set; } +} \ No newline at end of file diff --git a/server/Infrastructure/Extensions/InfrastructureConfiguration.cs b/server/Infrastructure/Extensions/InfrastructureConfiguration.cs index cc374e0..00bb6f2 100644 --- a/server/Infrastructure/Extensions/InfrastructureConfiguration.cs +++ b/server/Infrastructure/Extensions/InfrastructureConfiguration.cs @@ -71,6 +71,7 @@ private static void AddUsosOAuth(this IServiceCollection services, IConfiguratio .ValidateOnStart(); services.AddScoped(); + services.AddScoped(); } // NOTE: don't use this method in PRODUCTION enviroment to apply migrations during app startup. diff --git a/server/Infrastructure/Infrastructure.csproj b/server/Infrastructure/Infrastructure.csproj index 7364f2b..c46dc91 100644 --- a/server/Infrastructure/Infrastructure.csproj +++ b/server/Infrastructure/Infrastructure.csproj @@ -5,6 +5,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/server/Infrastructure/packages.lock.json b/server/Infrastructure/packages.lock.json index f86777b..4a91a8c 100644 --- a/server/Infrastructure/packages.lock.json +++ b/server/Infrastructure/packages.lock.json @@ -1,320 +1,6 @@ { - "version": 2, + "version": 1, "dependencies": { - "net10.0": { - "EFCore.NamingConventions": { - "type": "Direct", - "requested": "[10.0.1, )", - "resolved": "10.0.1", - "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" - } - }, - "FluentResults": { - "type": "Direct", - "requested": "[4.0.0, )", - "resolved": "4.0.0", - "contentHash": "wKfdLvDnDVhXlSreU7u/NFNHH6EfoOhAloEID4ePAyk+ZliowKZJvX+aCL4hxhFc9Jj74t62amS4LyOYTjXGCg==" - }, - "Microsoft.EntityFrameworkCore.Design": { - "type": "Direct", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "I/IwEkUEUoa62UMJHh2+04oAs+gZz4QEunpZ3MGtAf12PZUcTduZLuGZEp3FAZ7wkmkSThZez41P8tgFj8QYKQ==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.Build.Framework": "18.0.2", - "Microsoft.CodeAnalysis.CSharp": "5.0.0", - "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", - "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", - "Microsoft.EntityFrameworkCore.Relational": "10.0.12", - "Microsoft.Extensions.DependencyModel": "10.0.12", - "Mono.TextTemplating": "3.0.0", - "Newtonsoft.Json": "13.0.4" - } - }, - "Npgsql.EntityFrameworkCore.PostgreSQL": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", - "Npgsql": "10.0.3" - } - }, - "SonarAnalyzer.CSharp": { - "type": "Direct", - "requested": "[10.35.0.4138, )", - "resolved": "10.35.0.4138", - "contentHash": "T5o/tuyPw9pHRkyWxW07TkSri4I4pLpqwH922JuhphDjHAM7BUHaGE67h/w+65evHsIar8mYB2Ey6OmOLj9QDw==" - }, - "Castle.Core": { - "type": "Transitive", - "resolved": "5.2.1", - "contentHash": "wHARzQA695jwwKreOzNsq54KiGqKP38tv8hi8e2FXDEC/sA6BtrX90tVPDkOfVu13PbEzr00TCV8coikl+D1Iw==" - }, - "FluentValidation": { - "type": "Transitive", - "resolved": "12.1.1", - "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" - }, - "Humanizer.Core": { - "type": "Transitive", - "resolved": "2.14.1", - "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" - }, - "Microsoft.Build.Framework": { - "type": "Transitive", - "resolved": "18.0.2", - "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" - }, - "Microsoft.CodeAnalysis.Analyzers": { - "type": "Transitive", - "resolved": "3.11.0", - "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" - }, - "Microsoft.CodeAnalysis.Common": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0" - } - }, - "Microsoft.CodeAnalysis.CSharp": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[5.0.0]" - } - }, - "Microsoft.CodeAnalysis.CSharp.Workspaces": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", - "Microsoft.CodeAnalysis.Common": "[5.0.0]", - "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", - "System.Composition": "9.0.0" - } - }, - "Microsoft.CodeAnalysis.Workspaces.Common": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[5.0.0]", - "System.Composition": "9.0.0" - } - }, - "Microsoft.CodeAnalysis.Workspaces.MSBuild": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.Build.Framework": "17.11.31", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", - "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", - "Newtonsoft.Json": "13.0.3", - "System.Composition": "9.0.0" - } - }, - "Microsoft.EntityFrameworkCore.Abstractions": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg==" - }, - "Microsoft.EntityFrameworkCore.Analyzers": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ==" - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "rDPQVTxh/zMTDF7wHlRqL/jjZbwjAP6315ydBxj51pZW7qkAwGwjoSiMutxYI91xmOE3ZXjAGHbYRqzyJV7Urw==" - }, - "Microsoft.IdentityModel.Abstractions": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" - }, - "Microsoft.IdentityModel.JsonWebTokens": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "4jOpiA4THdtpLyMdAb24dtj7+6GmvhOhxf5XHLYWmPKF8ApEnApal1UnJsKO4HxUWRXDA6C4WQVfYyqsRhpNpQ==", - "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.14.0" - } - }, - "Microsoft.IdentityModel.Logging": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==", - "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.14.0" - } - }, - "Microsoft.IdentityModel.Tokens": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "lKIZiBiGd36k02TCdMHp1KlNWisyIvQxcYJvIkz7P4gSQ9zi8dgh6S5Grj8NNG7HWYIPfQymGyoZ6JB5d1Lo1g==", - "dependencies": { - "Microsoft.IdentityModel.Logging": "8.14.0" - } - }, - "Microsoft.VisualStudio.SolutionPersistence": { - "type": "Transitive", - "resolved": "1.0.52", - "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" - }, - "Mono.TextTemplating": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", - "dependencies": { - "System.CodeDom": "6.0.0" - } - }, - "Newtonsoft.Json": { - "type": "Transitive", - "resolved": "13.0.4", - "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" - }, - "System.CodeDom": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" - }, - "System.Composition": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", - "dependencies": { - "System.Composition.AttributedModel": "9.0.0", - "System.Composition.Convention": "9.0.0", - "System.Composition.Hosting": "9.0.0", - "System.Composition.Runtime": "9.0.0", - "System.Composition.TypedParts": "9.0.0" - } - }, - "System.Composition.AttributedModel": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" - }, - "System.Composition.Convention": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", - "dependencies": { - "System.Composition.AttributedModel": "9.0.0" - } - }, - "System.Composition.Hosting": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", - "dependencies": { - "System.Composition.Runtime": "9.0.0" - } - }, - "System.Composition.Runtime": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" - }, - "System.Composition.TypedParts": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", - "dependencies": { - "System.Composition.AttributedModel": "9.0.0", - "System.Composition.Hosting": "9.0.0", - "System.Composition.Runtime": "9.0.0" - } - }, - "application": { - "type": "Project", - "dependencies": { - "AutoMapper": "[16.3.0, )", - "Domain": "[1.0.0, )", - "FluentResults": "[4.0.0, )", - "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", - "Moq": "[4.21.0, )" - } - }, - "domain": { - "type": "Project", - "dependencies": { - "FluentResults": "[4.0.0, )" - } - }, - "AutoMapper": { - "type": "CentralTransitive", - "requested": "[16.3.0, )", - "resolved": "16.3.0", - "contentHash": "wFTqA37e1I4AOEhTTYlxrqDE8BQ4OB3dqhlv2KwYmHv+a4tn7R1JW2n2bzXb8eTBhWVBSl7iTZ0VxEt0a57D4w==", - "dependencies": { - "Microsoft.IdentityModel.JsonWebTokens": "8.14.0" - } - }, - "FluentValidation.DependencyInjectionExtensions": { - "type": "CentralTransitive", - "requested": "[12.1.1, )", - "resolved": "12.1.1", - "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", - "dependencies": { - "FluentValidation": "12.1.1" - } - }, - "Microsoft.EntityFrameworkCore": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.12", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.12" - } - }, - "Microsoft.EntityFrameworkCore.Relational": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.12" - } - }, - "Moq": { - "type": "CentralTransitive", - "requested": "[4.21.0, )", - "resolved": "4.21.0", - "contentHash": "HUjrhhzEJ/AfbSBCnR8lPkpveVGpV/dNuOlnpfqBNTME93izly1CRV4N5zBySqz4m2yCRlHLPy/PnPzYokbwIg==", - "dependencies": { - "Castle.Core": "5.2.1" - } - }, - "Npgsql": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==" - } - } + "net10.0": {} } } \ No newline at end of file diff --git a/server/Presentation/Controllers/AuthController.cs b/server/Presentation/Controllers/AuthController.cs index a4eccd8..deb8226 100644 --- a/server/Presentation/Controllers/AuthController.cs +++ b/server/Presentation/Controllers/AuthController.cs @@ -27,7 +27,7 @@ public async Task Login(CancellationToken ct) } [HttpGet("callback")] - [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status502BadGateway)] diff --git a/server/Presentation/Extensions/PresentationConfiguration.cs b/server/Presentation/Extensions/PresentationConfiguration.cs index 47ada56..06a134d 100644 --- a/server/Presentation/Extensions/PresentationConfiguration.cs +++ b/server/Presentation/Extensions/PresentationConfiguration.cs @@ -1,11 +1,16 @@ -using System.Text.Json; +using System; +using System.Text; +using System.Text.Json; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi; using Presentation.Transformers; @@ -63,6 +68,30 @@ private static void AddPutWikiOpenApi(this IServiceCollection services) }); } + private static void AddPutWikiAuthentication(this IServiceCollection services, IConfiguration configuration) + { + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.MapInboundClaims = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = configuration["Jwt:Issuer"], + ValidAudience = configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"]!)), + ClockSkew = TimeSpan.Zero + }; + }); + + services.AddAuthorization(); + } + public static IApplicationBuilder UsePutWikiOpenApiDocs(this WebApplication app) { if (app.Environment.IsDevelopment()) @@ -86,7 +115,7 @@ public static IApplicationBuilder UsePutWikiOpenApiDocs(this WebApplication app) return app; } - public static IServiceCollection AddPresentation(this IServiceCollection services) + public static IServiceCollection AddPresentation(this IServiceCollection services, IConfiguration configuration) { services.AddControllers(opts => opts.Conventions.Add( @@ -111,7 +140,10 @@ public static IServiceCollection AddPresentation(this IServiceCollection service }); services.AddPutWikiOpenApi(); + services.AddPutWikiAuthentication(configuration); return services; } + + } \ No newline at end of file diff --git a/server/Presentation/Presentation.csproj b/server/Presentation/Presentation.csproj index 77ad7b4..d5b3391 100644 --- a/server/Presentation/Presentation.csproj +++ b/server/Presentation/Presentation.csproj @@ -2,6 +2,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/server/Presentation/Program.cs b/server/Presentation/Program.cs index 0a6b1b3..b6a331c 100644 --- a/server/Presentation/Program.cs +++ b/server/Presentation/Program.cs @@ -25,7 +25,7 @@ builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddApplication(); -builder.Services.AddPresentation(); +builder.Services.AddPresentation(builder.Configuration); var app = builder.Build(); diff --git a/server/Presentation/packages.lock.json b/server/Presentation/packages.lock.json index 9af254c..4a91a8c 100644 --- a/server/Presentation/packages.lock.json +++ b/server/Presentation/packages.lock.json @@ -1,375 +1,6 @@ { - "version": 2, + "version": 1, "dependencies": { - "net10.0": { - "DotNetEnv": { - "type": "Direct", - "requested": "[3.2.0, )", - "resolved": "3.2.0", - "contentHash": "2q+PD+aJcRW9QuRcIv4F8WbvEDxI6cwxzZJec2i45cBY4cAKCAoW4ZPkApJ7H82wr6Yfmxx/0kb1nLnRmZrGZA==", - "dependencies": { - "Superpower": "3.0.0" - } - }, - "FluentResults": { - "type": "Direct", - "requested": "[4.0.0, )", - "resolved": "4.0.0", - "contentHash": "wKfdLvDnDVhXlSreU7u/NFNHH6EfoOhAloEID4ePAyk+ZliowKZJvX+aCL4hxhFc9Jj74t62amS4LyOYTjXGCg==" - }, - "Microsoft.AspNetCore.OpenApi": { - "type": "Direct", - "requested": "[10.0.14, )", - "resolved": "10.0.14", - "contentHash": "3M6yO4nv8fmvM/1X7DqQcMxw/ptNLBcUzZt19SBEyfZF32gqZuwoPNnYd1xlDnFvQOGOkZDncf7p9E24blcTkQ==", - "dependencies": { - "Microsoft.OpenApi": "[2.12.2, 3.0.0)" - } - }, - "Microsoft.EntityFrameworkCore.Design": { - "type": "Direct", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "I/IwEkUEUoa62UMJHh2+04oAs+gZz4QEunpZ3MGtAf12PZUcTduZLuGZEp3FAZ7wkmkSThZez41P8tgFj8QYKQ==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.Build.Framework": "18.0.2", - "Microsoft.CodeAnalysis.CSharp": "5.0.0", - "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", - "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", - "Microsoft.EntityFrameworkCore.Relational": "10.0.12", - "Microsoft.Extensions.DependencyModel": "10.0.12", - "Mono.TextTemplating": "3.0.0", - "Newtonsoft.Json": "13.0.4" - } - }, - "Scalar.AspNetCore": { - "type": "Direct", - "requested": "[2.19.0, )", - "resolved": "2.19.0", - "contentHash": "nTXL9wsg6UhQ27KPUhuMr3/BE02H3zjuii2I5x7vbx7myPHkJzvzGDj7hfd1lKZBmJgUcKI+W7wcIzDdV/N/Dg==" - }, - "Scalar.AspNetCore.Microsoft": { - "type": "Direct", - "requested": "[2.19.0, )", - "resolved": "2.19.0", - "contentHash": "k4i3WbQV3XOgdKg9ez4F3fXB7alq68r7Rh6+WFOjeGREKuMPAuPa7djZVz/NKTjl2Lde8lnsCkArd1dAE+xB7A==", - "dependencies": { - "Microsoft.AspNetCore.OpenApi": "10.0.0", - "Microsoft.OpenApi": "2.7.5", - "Scalar.AspNetCore": "2.19.0" - } - }, - "SonarAnalyzer.CSharp": { - "type": "Direct", - "requested": "[10.35.0.4138, )", - "resolved": "10.35.0.4138", - "contentHash": "T5o/tuyPw9pHRkyWxW07TkSri4I4pLpqwH922JuhphDjHAM7BUHaGE67h/w+65evHsIar8mYB2Ey6OmOLj9QDw==" - }, - "Castle.Core": { - "type": "Transitive", - "resolved": "5.2.1", - "contentHash": "wHARzQA695jwwKreOzNsq54KiGqKP38tv8hi8e2FXDEC/sA6BtrX90tVPDkOfVu13PbEzr00TCV8coikl+D1Iw==" - }, - "FluentValidation": { - "type": "Transitive", - "resolved": "12.1.1", - "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" - }, - "Humanizer.Core": { - "type": "Transitive", - "resolved": "2.14.1", - "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" - }, - "Microsoft.Build.Framework": { - "type": "Transitive", - "resolved": "18.0.2", - "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" - }, - "Microsoft.CodeAnalysis.Analyzers": { - "type": "Transitive", - "resolved": "3.11.0", - "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" - }, - "Microsoft.CodeAnalysis.Common": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0" - } - }, - "Microsoft.CodeAnalysis.CSharp": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[5.0.0]" - } - }, - "Microsoft.CodeAnalysis.CSharp.Workspaces": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", - "Microsoft.CodeAnalysis.Common": "[5.0.0]", - "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", - "System.Composition": "9.0.0" - } - }, - "Microsoft.CodeAnalysis.Workspaces.Common": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[5.0.0]", - "System.Composition": "9.0.0" - } - }, - "Microsoft.CodeAnalysis.Workspaces.MSBuild": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.Build.Framework": "17.11.31", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", - "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", - "Newtonsoft.Json": "13.0.3", - "System.Composition": "9.0.0" - } - }, - "Microsoft.EntityFrameworkCore.Abstractions": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg==" - }, - "Microsoft.EntityFrameworkCore.Analyzers": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ==" - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "rDPQVTxh/zMTDF7wHlRqL/jjZbwjAP6315ydBxj51pZW7qkAwGwjoSiMutxYI91xmOE3ZXjAGHbYRqzyJV7Urw==" - }, - "Microsoft.IdentityModel.Abstractions": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" - }, - "Microsoft.IdentityModel.JsonWebTokens": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "4jOpiA4THdtpLyMdAb24dtj7+6GmvhOhxf5XHLYWmPKF8ApEnApal1UnJsKO4HxUWRXDA6C4WQVfYyqsRhpNpQ==", - "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.14.0" - } - }, - "Microsoft.IdentityModel.Logging": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==", - "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.14.0" - } - }, - "Microsoft.IdentityModel.Tokens": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "lKIZiBiGd36k02TCdMHp1KlNWisyIvQxcYJvIkz7P4gSQ9zi8dgh6S5Grj8NNG7HWYIPfQymGyoZ6JB5d1Lo1g==", - "dependencies": { - "Microsoft.IdentityModel.Logging": "8.14.0" - } - }, - "Microsoft.OpenApi": { - "type": "Transitive", - "resolved": "2.12.2", - "contentHash": "ZkT7qiJe0Kj0+DTZOZo+xrDy1kNmdsWFQVPIQLNx9ZJ0T9cB2LG7r+Bi60so8/+yg1nkS7QCPadrzsTG4VPKrA==" - }, - "Microsoft.VisualStudio.SolutionPersistence": { - "type": "Transitive", - "resolved": "1.0.52", - "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" - }, - "Mono.TextTemplating": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", - "dependencies": { - "System.CodeDom": "6.0.0" - } - }, - "Newtonsoft.Json": { - "type": "Transitive", - "resolved": "13.0.4", - "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" - }, - "Superpower": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "bjKbAYWePooCAvPxQq+3KnmcLfRggMyRoXmtBMlmCG71bdwBHrO6rmRJ2DRIodg5aztNcxeZXBUVWT+H+MZUhw==" - }, - "System.CodeDom": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" - }, - "System.Composition": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", - "dependencies": { - "System.Composition.AttributedModel": "9.0.0", - "System.Composition.Convention": "9.0.0", - "System.Composition.Hosting": "9.0.0", - "System.Composition.Runtime": "9.0.0", - "System.Composition.TypedParts": "9.0.0" - } - }, - "System.Composition.AttributedModel": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" - }, - "System.Composition.Convention": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", - "dependencies": { - "System.Composition.AttributedModel": "9.0.0" - } - }, - "System.Composition.Hosting": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", - "dependencies": { - "System.Composition.Runtime": "9.0.0" - } - }, - "System.Composition.Runtime": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" - }, - "System.Composition.TypedParts": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", - "dependencies": { - "System.Composition.AttributedModel": "9.0.0", - "System.Composition.Hosting": "9.0.0", - "System.Composition.Runtime": "9.0.0" - } - }, - "application": { - "type": "Project", - "dependencies": { - "AutoMapper": "[16.3.0, )", - "Domain": "[1.0.0, )", - "FluentResults": "[4.0.0, )", - "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", - "Moq": "[4.21.0, )" - } - }, - "domain": { - "type": "Project", - "dependencies": { - "FluentResults": "[4.0.0, )" - } - }, - "infrastructure": { - "type": "Project", - "dependencies": { - "Application": "[1.0.0, )", - "Domain": "[1.0.0, )", - "EFCore.NamingConventions": "[10.0.1, )", - "FluentResults": "[4.0.0, )", - "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" - } - }, - "AutoMapper": { - "type": "CentralTransitive", - "requested": "[16.3.0, )", - "resolved": "16.3.0", - "contentHash": "wFTqA37e1I4AOEhTTYlxrqDE8BQ4OB3dqhlv2KwYmHv+a4tn7R1JW2n2bzXb8eTBhWVBSl7iTZ0VxEt0a57D4w==", - "dependencies": { - "Microsoft.IdentityModel.JsonWebTokens": "8.14.0" - } - }, - "EFCore.NamingConventions": { - "type": "CentralTransitive", - "requested": "[10.0.1, )", - "resolved": "10.0.1", - "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" - } - }, - "FluentValidation.DependencyInjectionExtensions": { - "type": "CentralTransitive", - "requested": "[12.1.1, )", - "resolved": "12.1.1", - "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", - "dependencies": { - "FluentValidation": "12.1.1" - } - }, - "Microsoft.EntityFrameworkCore": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.12", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.12" - } - }, - "Microsoft.EntityFrameworkCore.Relational": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.12" - } - }, - "Moq": { - "type": "CentralTransitive", - "requested": "[4.21.0, )", - "resolved": "4.21.0", - "contentHash": "HUjrhhzEJ/AfbSBCnR8lPkpveVGpV/dNuOlnpfqBNTME93izly1CRV4N5zBySqz4m2yCRlHLPy/PnPzYokbwIg==", - "dependencies": { - "Castle.Core": "5.2.1" - } - }, - "Npgsql": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==" - }, - "Npgsql.EntityFrameworkCore.PostgreSQL": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", - "Npgsql": "10.0.3" - } - } - } + "net10.0": {} } } \ No newline at end of file diff --git a/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTests.cs b/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTests.cs index 2f11db4..2332038 100644 --- a/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTests.cs +++ b/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTests.cs @@ -30,6 +30,7 @@ public class ProvisionUserCommandHandlerTests private readonly Mock _userRepositoryMock; private readonly Mock _iUnitOfWorkMock; private readonly FakeTimeProvider _fakeTimeProvider; + private readonly Mock _userSessionServiceMock; private readonly ProvisionUserCommandHandler _sut; public ProvisionUserCommandHandlerTests() @@ -39,12 +40,7 @@ public ProvisionUserCommandHandlerTests() _userRepositoryMock = new Mock(); _iUnitOfWorkMock = new Mock(); _fakeTimeProvider = new FakeTimeProvider(); - - var mapperConfig = new MapperConfiguration(cfg => - { - cfg.AddProfile(); - }, new NullLoggerFactory()); - IMapper mapper = mapperConfig.CreateMapper(); + _userSessionServiceMock = new Mock(); _sut = new ProvisionUserCommandHandler( _usosOAuthServiceMock.Object, @@ -52,8 +48,8 @@ public ProvisionUserCommandHandlerTests() _userRepositoryMock.Object, _iUnitOfWorkMock.Object, NullLogger.Instance, - mapper, - _fakeTimeProvider + _fakeTimeProvider, + _userSessionServiceMock.Object ); } @@ -107,8 +103,6 @@ public async Task ExecuteAsync_WhenUserAlreadyExistsInDb_ShouldReturnExistingUse // Assert result.IsSuccess.Should().BeTrue(); - result.Value.Id.Should().Be(existingUser.Id.ToString()); - result.Value.HashedUsosId.Should().Be(hashedUsosId); _userRepositoryMock.Verify(x => x.Add(It.IsAny()), Times.Never); _iUnitOfWorkMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Never); @@ -142,9 +136,6 @@ public async Task ExecuteAsync_WhenUserDoesNotExistInDb_ShouldCreateNewUserAndSa // Assert result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBeNull(); - result.Value.HashedUsosId.Should().Be(hashedUsosId); - result.Value.Id.Should().NotBe(Guid.Empty.ToString()); - result.Value.JoinedDate.Should().Be(fakeDate); _userRepositoryMock.Verify(x => x.Add(It.Is(u => u.HashedUsosId == hashedUsosId && u.JoinedDate == fakeDate)), Times.Once); _iUnitOfWorkMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); diff --git a/server/PutWiki.UnitTests/packages.lock.json b/server/PutWiki.UnitTests/packages.lock.json index f8cfb8a..4a91a8c 100644 --- a/server/PutWiki.UnitTests/packages.lock.json +++ b/server/PutWiki.UnitTests/packages.lock.json @@ -1,367 +1,6 @@ { - "version": 2, + "version": 1, "dependencies": { - "net10.0": { - "coverlet.collector": { - "type": "Direct", - "requested": "[10.1.0, )", - "resolved": "10.1.0", - "contentHash": "SlYCIHnBYxINSUpR75SiM5JEmnx11pqT+Ac/C/fUqTTA0dLPl9eYzmFXKKuxmQhL3ZdTQl4PBD3YdAxAauaKLQ==" - }, - "FluentAssertions": { - "type": "Direct", - "requested": "[8.11.0, )", - "resolved": "8.11.0", - "contentHash": "dZqOxys9uY/0vO5BAwFtDECkFNSr9vWGwHa1fwZyuM1xAMbI4wiPTUBR2nH5e6R2/bQkXegxFY1B/oU1yqNNOw==" - }, - "FluentResults": { - "type": "Direct", - "requested": "[4.0.0, )", - "resolved": "4.0.0", - "contentHash": "wKfdLvDnDVhXlSreU7u/NFNHH6EfoOhAloEID4ePAyk+ZliowKZJvX+aCL4hxhFc9Jj74t62amS4LyOYTjXGCg==", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "2.1.1" - } - }, - "Microsoft.Extensions.TimeProvider.Testing": { - "type": "Direct", - "requested": "[10.11.0, )", - "resolved": "10.11.0", - "contentHash": "DTivKZ2Du0dJideTTa6vPj/eXKNRu+RxtjBX7NOOUXHBtHx1ehbE+lM+j3/5M3wU6BqzcZqEEyR4+FnPKs2rTA==" - }, - "Microsoft.NET.Test.Sdk": { - "type": "Direct", - "requested": "[18.11.0, )", - "resolved": "18.11.0", - "contentHash": "xf/bR0LNkhK0SnadDRlZIQmC4sac5Wk0e1WAdiQjRN3MSo6gQ9M2c4c1XRNnVGjfOvckh704W5CJ19AL0/Xsdg==", - "dependencies": { - "Microsoft.CodeCoverage": "18.11.0", - "Microsoft.TestPlatform.TestHost": "18.11.0" - } - }, - "SonarAnalyzer.CSharp": { - "type": "Direct", - "requested": "[10.35.0.4138, )", - "resolved": "10.35.0.4138", - "contentHash": "T5o/tuyPw9pHRkyWxW07TkSri4I4pLpqwH922JuhphDjHAM7BUHaGE67h/w+65evHsIar8mYB2Ey6OmOLj9QDw==" - }, - "xunit": { - "type": "Direct", - "requested": "[2.9.3, )", - "resolved": "2.9.3", - "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", - "dependencies": { - "xunit.analyzers": "1.18.0", - "xunit.assert": "2.9.3", - "xunit.core": "[2.9.3]" - } - }, - "xunit.runner.visualstudio": { - "type": "Direct", - "requested": "[4.0.1, )", - "resolved": "4.0.1", - "contentHash": "8CoAaZkniVekR1ycJyxn+hOz+6ZQCDPcoIsIs6ujd4WyFcBMlxGNU15Qb8Uxd4iO4UmOuETb0M6vzBbwX/mfLA==" - }, - "Castle.Core": { - "type": "Transitive", - "resolved": "5.2.1", - "contentHash": "wHARzQA695jwwKreOzNsq54KiGqKP38tv8hi8e2FXDEC/sA6BtrX90tVPDkOfVu13PbEzr00TCV8coikl+D1Iw==", - "dependencies": { - "System.Diagnostics.EventLog": "6.0.0" - } - }, - "FluentValidation": { - "type": "Transitive", - "resolved": "12.1.1", - "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" - }, - "Microsoft.CodeCoverage": { - "type": "Transitive", - "resolved": "18.11.0", - "contentHash": "MhN9wpvL1Es4hfIYslrsVgIsUdDFxuOBTbAl9mGIMvFo8PP5CLf4DrGdNrfwWSyn0lqi6sssm+I/qkkLL3MU+g==" - }, - "Microsoft.EntityFrameworkCore.Abstractions": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg==" - }, - "Microsoft.EntityFrameworkCore.Analyzers": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ==" - }, - "Microsoft.Extensions.Caching.Abstractions": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "sI3A6MUAyZwccLnwq0cXuRmC3WBtXieimBWafXSfhRJ4jnM2uGVXrqImDDVyW27u4vVaTOGlJxNJd8d1FGvCNQ==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.12" - } - }, - "Microsoft.Extensions.Caching.Memory": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "fhC3DHcBcFF4bXvHIxFtXJYNut7q7ZItqNFcgWfrIgmpVImlcEKNHhE7ztpNSPJYMHYYStS5QGND2I5mC26yeA==", - "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.12", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", - "Microsoft.Extensions.Logging.Abstractions": "10.0.12", - "Microsoft.Extensions.Options": "10.0.12", - "Microsoft.Extensions.Primitives": "10.0.12" - } - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.12" - } - }, - "Microsoft.Extensions.DependencyInjection": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" - }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.12", - "Microsoft.Extensions.Logging.Abstractions": "10.0.12", - "Microsoft.Extensions.Options": "10.0.12" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" - } - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", - "Microsoft.Extensions.Primitives": "10.0.12" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" - }, - "Microsoft.IdentityModel.Abstractions": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" - }, - "Microsoft.IdentityModel.JsonWebTokens": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "4jOpiA4THdtpLyMdAb24dtj7+6GmvhOhxf5XHLYWmPKF8ApEnApal1UnJsKO4HxUWRXDA6C4WQVfYyqsRhpNpQ==", - "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.14.0" - } - }, - "Microsoft.IdentityModel.Logging": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==", - "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.14.0" - } - }, - "Microsoft.IdentityModel.Tokens": { - "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "lKIZiBiGd36k02TCdMHp1KlNWisyIvQxcYJvIkz7P4gSQ9zi8dgh6S5Grj8NNG7HWYIPfQymGyoZ6JB5d1Lo1g==", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "8.0.0", - "Microsoft.IdentityModel.Logging": "8.14.0" - } - }, - "Microsoft.TestPlatform.ObjectModel": { - "type": "Transitive", - "resolved": "18.11.0", - "contentHash": "BXBHYnwSOljKute8UYCMXOLAdB7awxEMppPSpqOF1ZxLD/W/OH+vTBgFaXA7q0aditJTVXSMVenel+wOkEIyNg==" - }, - "Microsoft.TestPlatform.TestHost": { - "type": "Transitive", - "resolved": "18.11.0", - "contentHash": "AKjwKpyRBwoQFQmpcchtoYlfJEBlaY/40uxBRi+qXP4NR7xT8t+XTJtOyEyumfvfJcH5pASs75LwNdXYRr4XNA==", - "dependencies": { - "Microsoft.TestPlatform.ObjectModel": "18.11.0" - } - }, - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" - }, - "xunit.abstractions": { - "type": "Transitive", - "resolved": "2.0.3", - "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" - }, - "xunit.analyzers": { - "type": "Transitive", - "resolved": "1.18.0", - "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" - }, - "xunit.assert": { - "type": "Transitive", - "resolved": "2.9.3", - "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" - }, - "xunit.core": { - "type": "Transitive", - "resolved": "2.9.3", - "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", - "dependencies": { - "xunit.extensibility.core": "[2.9.3]", - "xunit.extensibility.execution": "[2.9.3]" - } - }, - "xunit.extensibility.core": { - "type": "Transitive", - "resolved": "2.9.3", - "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", - "dependencies": { - "xunit.abstractions": "2.0.3" - } - }, - "xunit.extensibility.execution": { - "type": "Transitive", - "resolved": "2.9.3", - "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", - "dependencies": { - "xunit.extensibility.core": "[2.9.3]" - } - }, - "application": { - "type": "Project", - "dependencies": { - "AutoMapper": "[16.3.0, )", - "Domain": "[1.0.0, )", - "FluentResults": "[4.0.0, )", - "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", - "Moq": "[4.21.0, )" - } - }, - "domain": { - "type": "Project", - "dependencies": { - "FluentResults": "[4.0.0, )" - } - }, - "infrastructure": { - "type": "Project", - "dependencies": { - "Application": "[1.0.0, )", - "Domain": "[1.0.0, )", - "EFCore.NamingConventions": "[10.0.1, )", - "FluentResults": "[4.0.0, )", - "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" - } - }, - "AutoMapper": { - "type": "CentralTransitive", - "requested": "[16.3.0, )", - "resolved": "16.3.0", - "contentHash": "wFTqA37e1I4AOEhTTYlxrqDE8BQ4OB3dqhlv2KwYmHv+a4tn7R1JW2n2bzXb8eTBhWVBSl7iTZ0VxEt0a57D4w==", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0", - "Microsoft.IdentityModel.JsonWebTokens": "8.14.0" - } - }, - "EFCore.NamingConventions": { - "type": "CentralTransitive", - "requested": "[10.0.1, )", - "resolved": "10.0.1", - "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" - } - }, - "FluentValidation.DependencyInjectionExtensions": { - "type": "CentralTransitive", - "requested": "[12.1.1, )", - "resolved": "12.1.1", - "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", - "dependencies": { - "FluentValidation": "12.1.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" - } - }, - "Microsoft.EntityFrameworkCore": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.12", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.12", - "Microsoft.Extensions.Caching.Memory": "10.0.12", - "Microsoft.Extensions.Logging": "10.0.12" - } - }, - "Microsoft.EntityFrameworkCore.Relational": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.12", - "Microsoft.Extensions.Caching.Memory": "10.0.12", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", - "Microsoft.Extensions.Logging": "10.0.12" - } - }, - "Moq": { - "type": "CentralTransitive", - "requested": "[4.21.0, )", - "resolved": "4.21.0", - "contentHash": "HUjrhhzEJ/AfbSBCnR8lPkpveVGpV/dNuOlnpfqBNTME93izly1CRV4N5zBySqz4m2yCRlHLPy/PnPzYokbwIg==", - "dependencies": { - "Castle.Core": "5.2.1" - } - }, - "Npgsql": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0" - } - }, - "Npgsql.EntityFrameworkCore.PostgreSQL": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", - "Npgsql": "10.0.3" - } - } - } + "net10.0": {} } } \ No newline at end of file From 1688fcce172ca31b1a18aedefe974316290d65b1 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 22 Oct 2026 19:46:24 +0200 Subject: [PATCH 03/33] refactor: update infrastructure config for jwt settings --- server/.env.example | 1 + .../Auth/Configuration/JwtTokensSettings.cs | 8 ++++++ .../JwtTokensSettingsValidator.cs | 21 ++++++++++++++ .../Auth/JwtUserSessionService.cs | 12 ++++---- .../Extensions/InfrastructureConfiguration.cs | 28 +++++++++++++++++-- server/Presentation/appsettings.json | 4 +++ 6 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 server/Infrastructure/Auth/Configuration/JwtTokensSettings.cs create mode 100644 server/Infrastructure/Auth/Configuration/JwtTokensSettingsValidator.cs diff --git a/server/.env.example b/server/.env.example index f228e67..1caec94 100644 --- a/server/.env.example +++ b/server/.env.example @@ -2,3 +2,4 @@ UsosOAuth__ConsumerKey=your-usos-consumer-key UsosOAuth__ConsumerSecret=your-usos-consumer-secret UsosOAuth__HashingKey=your-64-bytes-length-hashing-key-for-user-usos-id ConnectionStrings__DefaultConnection=Host=localhost;Port=5433;Database=putwiki;Username=postgres;Password=password +Jwt__SecretKey=your-32-bytes-length-secret-key-for-signing-jwt-tokens diff --git a/server/Infrastructure/Auth/Configuration/JwtTokensSettings.cs b/server/Infrastructure/Auth/Configuration/JwtTokensSettings.cs new file mode 100644 index 0000000..19e249d --- /dev/null +++ b/server/Infrastructure/Auth/Configuration/JwtTokensSettings.cs @@ -0,0 +1,8 @@ +namespace Infrastructure.Auth.Configuration; + +public class JwtTokensSettings +{ + public string Issuer { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public string SecretKey { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/server/Infrastructure/Auth/Configuration/JwtTokensSettingsValidator.cs b/server/Infrastructure/Auth/Configuration/JwtTokensSettingsValidator.cs new file mode 100644 index 0000000..9b742d5 --- /dev/null +++ b/server/Infrastructure/Auth/Configuration/JwtTokensSettingsValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +namespace Infrastructure.Auth.Configuration; + +public class JwtTokensSettingsValidator : AbstractValidator +{ + public JwtTokensSettingsValidator() + { + RuleFor(x => x.Issuer) + .NotEmpty() + .WithMessage("Missing issuer name."); + + RuleFor(x => x.Audience) + .NotEmpty() + .WithMessage("Missing audience name."); + + RuleFor(x => x.SecretKey) + .NotEmpty() + .WithMessage("Missing jwt signing key."); + } +} \ No newline at end of file diff --git a/server/Infrastructure/Auth/JwtUserSessionService.cs b/server/Infrastructure/Auth/JwtUserSessionService.cs index 63fd971..ba869ad 100644 --- a/server/Infrastructure/Auth/JwtUserSessionService.cs +++ b/server/Infrastructure/Auth/JwtUserSessionService.cs @@ -6,14 +6,16 @@ using Application.Features.Users.Commands.ProvisionUser; -using Microsoft.Extensions.Configuration; +using Infrastructure.Auth.Configuration; + +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; namespace Infrastructure.Auth; public sealed class JwtUserSessionService( - IConfiguration configuration, + IOptions settings, AppDbContext dbContext, TimeProvider timeProvider ) : IUserSessionService @@ -44,7 +46,7 @@ private string GenerateAccessToken(Guid userId) }; var key = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"]!)); + Encoding.UTF8.GetBytes(settings.Value.SecretKey)); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); @@ -53,8 +55,8 @@ private string GenerateAccessToken(Guid userId) Subject = new ClaimsIdentity(claims), Expires = timeProvider.GetUtcNow().AddMinutes(30).DateTime, SigningCredentials = credentials, - Issuer = configuration["Jwt:Issuer"], - Audience = configuration["Jwt:Audience"] + Issuer = settings.Value.Issuer, + Audience = settings.Value.Audience }; return new JsonWebTokenHandler().CreateToken(tokenDescriptor); diff --git a/server/Infrastructure/Extensions/InfrastructureConfiguration.cs b/server/Infrastructure/Extensions/InfrastructureConfiguration.cs index 00bb6f2..65dfe40 100644 --- a/server/Infrastructure/Extensions/InfrastructureConfiguration.cs +++ b/server/Infrastructure/Extensions/InfrastructureConfiguration.cs @@ -21,6 +21,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Infrastructure.Extensions; @@ -46,10 +47,34 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddUsosOAuth(configuration); + services.AddJwtAuth(configuration); return services; } + private static void AddJwtAuth(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection("Jwt")) + .Validate(settings => + { + var validator = new JwtTokensSettingsValidator(); + var validationResult = validator.Validate(settings); + if (!validationResult.IsValid) + { + throw new OptionsValidationException( + "JwtTokensSettings", + typeof(JwtTokensSettings), + validationResult.Errors.Select(e => e.ErrorMessage) + ); + } + return true; + }) + .ValidateOnStart(); + + services.AddScoped(); + } + private static void AddUsosOAuth(this IServiceCollection services, IConfiguration configuration) { services.AddOptions() @@ -60,7 +85,7 @@ private static void AddUsosOAuth(this IServiceCollection services, IConfiguratio var validationResult = validator.Validate(settings); if (!validationResult.IsValid) { - throw new Microsoft.Extensions.Options.OptionsValidationException( + throw new OptionsValidationException( "UsosOAuthSettings", typeof(UsosOAuthSettings), validationResult.Errors.Select(e => e.ErrorMessage) @@ -71,7 +96,6 @@ private static void AddUsosOAuth(this IServiceCollection services, IConfiguratio .ValidateOnStart(); services.AddScoped(); - services.AddScoped(); } // NOTE: don't use this method in PRODUCTION enviroment to apply migrations during app startup. diff --git a/server/Presentation/appsettings.json b/server/Presentation/appsettings.json index 495eae7..7ca5538 100644 --- a/server/Presentation/appsettings.json +++ b/server/Presentation/appsettings.json @@ -18,5 +18,9 @@ "UsosOAuth": { "BaseUrl": "https://usosapps.put.poznan.pl/", "Scopes": "studies" + }, + "Jwt": { + "Issuer": "putwiki-api", + "Audience": "putwiki-frontend" } } From 3b43b90575f6c6405a08f378ca342f056a5fa394 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 22 Oct 2026 19:49:36 +0200 Subject: [PATCH 04/33] feat(AppDbContext): configure index on RefreshToken entity --- server/Infrastructure/AppDbContext.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/Infrastructure/AppDbContext.cs b/server/Infrastructure/AppDbContext.cs index a2e6354..5bc1937 100644 --- a/server/Infrastructure/AppDbContext.cs +++ b/server/Infrastructure/AppDbContext.cs @@ -25,5 +25,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) new UserEntityTypeConfiguration().Configure(modelBuilder.Entity()); new AcademicTeacherEntityTypeConfiguration().Configure(modelBuilder.Entity()); + + modelBuilder.Entity() + .HasIndex(b => b.Token); } } \ No newline at end of file From 9944a377d9454d55a1624ee2fd7ca4ddb84f4e42 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 22 Oct 2026 20:05:49 +0200 Subject: [PATCH 05/33] refactor: store in config tokens lifetime --- .../Auth/Configuration/JwtSettings.cs | 14 ++++++++++++++ .../Auth/Configuration/JwtTokensSettings.cs | 8 -------- .../Configuration/JwtTokensSettingsValidator.cs | 2 +- .../Auth/Configuration/UsosOAuthSettings.cs | 2 ++ .../Infrastructure/Auth/JwtUserSessionService.cs | 6 +++--- .../Extensions/InfrastructureConfiguration.cs | 10 +++++----- server/Presentation/appsettings.json | 4 +++- 7 files changed, 28 insertions(+), 18 deletions(-) create mode 100644 server/Infrastructure/Auth/Configuration/JwtSettings.cs delete mode 100644 server/Infrastructure/Auth/Configuration/JwtTokensSettings.cs diff --git a/server/Infrastructure/Auth/Configuration/JwtSettings.cs b/server/Infrastructure/Auth/Configuration/JwtSettings.cs new file mode 100644 index 0000000..aafdac0 --- /dev/null +++ b/server/Infrastructure/Auth/Configuration/JwtSettings.cs @@ -0,0 +1,14 @@ +using System; + +namespace Infrastructure.Auth.Configuration; + +public class JwtSettings +{ + public const string SectionName = "Jwt"; + + public string Issuer { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public string SecretKey { get; set; } = string.Empty; + public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromMinutes(30); + public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(7); +} \ No newline at end of file diff --git a/server/Infrastructure/Auth/Configuration/JwtTokensSettings.cs b/server/Infrastructure/Auth/Configuration/JwtTokensSettings.cs deleted file mode 100644 index 19e249d..0000000 --- a/server/Infrastructure/Auth/Configuration/JwtTokensSettings.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Infrastructure.Auth.Configuration; - -public class JwtTokensSettings -{ - public string Issuer { get; set; } = string.Empty; - public string Audience { get; set; } = string.Empty; - public string SecretKey { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/server/Infrastructure/Auth/Configuration/JwtTokensSettingsValidator.cs b/server/Infrastructure/Auth/Configuration/JwtTokensSettingsValidator.cs index 9b742d5..8992595 100644 --- a/server/Infrastructure/Auth/Configuration/JwtTokensSettingsValidator.cs +++ b/server/Infrastructure/Auth/Configuration/JwtTokensSettingsValidator.cs @@ -2,7 +2,7 @@ namespace Infrastructure.Auth.Configuration; -public class JwtTokensSettingsValidator : AbstractValidator +public class JwtTokensSettingsValidator : AbstractValidator { public JwtTokensSettingsValidator() { diff --git a/server/Infrastructure/Auth/Configuration/UsosOAuthSettings.cs b/server/Infrastructure/Auth/Configuration/UsosOAuthSettings.cs index 83fcd21..38e7743 100644 --- a/server/Infrastructure/Auth/Configuration/UsosOAuthSettings.cs +++ b/server/Infrastructure/Auth/Configuration/UsosOAuthSettings.cs @@ -2,6 +2,8 @@ namespace Infrastructure.Auth.Configuration; public class UsosOAuthSettings { + public const string SectionName = "UsosOAuth"; + public string ConsumerKey { get; set; } = string.Empty; public string ConsumerSecret { get; set; } = string.Empty; public string BaseUrl { get; set; } = string.Empty; diff --git a/server/Infrastructure/Auth/JwtUserSessionService.cs b/server/Infrastructure/Auth/JwtUserSessionService.cs index ba869ad..262f182 100644 --- a/server/Infrastructure/Auth/JwtUserSessionService.cs +++ b/server/Infrastructure/Auth/JwtUserSessionService.cs @@ -15,7 +15,7 @@ namespace Infrastructure.Auth; public sealed class JwtUserSessionService( - IOptions settings, + IOptions settings, AppDbContext dbContext, TimeProvider timeProvider ) : IUserSessionService @@ -29,7 +29,7 @@ TimeProvider timeProvider Id = Guid.NewGuid(), UserId = userId, Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)), - ExpiresAt = timeProvider.GetUtcNow().AddDays(7).DateTime, + ExpiresAt = timeProvider.GetUtcNow().Add(settings.Value.RefreshTokenLifetime).DateTime, CreatedAt = timeProvider.GetUtcNow().DateTime }; @@ -53,7 +53,7 @@ private string GenerateAccessToken(Guid userId) var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(claims), - Expires = timeProvider.GetUtcNow().AddMinutes(30).DateTime, + Expires = timeProvider.GetUtcNow().Add(settings.Value.AccessTokenLifetime).DateTime, SigningCredentials = credentials, Issuer = settings.Value.Issuer, Audience = settings.Value.Audience diff --git a/server/Infrastructure/Extensions/InfrastructureConfiguration.cs b/server/Infrastructure/Extensions/InfrastructureConfiguration.cs index 65dfe40..d2257bc 100644 --- a/server/Infrastructure/Extensions/InfrastructureConfiguration.cs +++ b/server/Infrastructure/Extensions/InfrastructureConfiguration.cs @@ -54,8 +54,8 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi private static void AddJwtAuth(this IServiceCollection services, IConfiguration configuration) { - services.AddOptions() - .Bind(configuration.GetSection("Jwt")) + services.AddOptions() + .Bind(configuration.GetSection(JwtSettings.SectionName)) .Validate(settings => { var validator = new JwtTokensSettingsValidator(); @@ -63,8 +63,8 @@ private static void AddJwtAuth(this IServiceCollection services, IConfiguration if (!validationResult.IsValid) { throw new OptionsValidationException( - "JwtTokensSettings", - typeof(JwtTokensSettings), + "JwtSettings", + typeof(JwtSettings), validationResult.Errors.Select(e => e.ErrorMessage) ); } @@ -78,7 +78,7 @@ private static void AddJwtAuth(this IServiceCollection services, IConfiguration private static void AddUsosOAuth(this IServiceCollection services, IConfiguration configuration) { services.AddOptions() - .Bind(configuration.GetSection("UsosOAuth")) + .Bind(configuration.GetSection(UsosOAuthSettings.SectionName)) .Validate(settings => { var validator = new UsosOAuthSettingsValidator(); diff --git a/server/Presentation/appsettings.json b/server/Presentation/appsettings.json index 7ca5538..b00cabd 100644 --- a/server/Presentation/appsettings.json +++ b/server/Presentation/appsettings.json @@ -21,6 +21,8 @@ }, "Jwt": { "Issuer": "putwiki-api", - "Audience": "putwiki-frontend" + "Audience": "putwiki-frontend", + "AccessTokenLifetime": "00:30:00", + "RefreshTokenLifetime": "7.00:00:00" } } From 45f67d55a5e8d80b94968d1b983f7617300a894e Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 22 Oct 2026 20:48:54 +0200 Subject: [PATCH 06/33] refactor: use DateTimeOffset to prevent DST shift issues --- server/Infrastructure/Auth/JwtUserSessionService.cs | 6 +++--- server/Infrastructure/Auth/RefreshToken.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/Infrastructure/Auth/JwtUserSessionService.cs b/server/Infrastructure/Auth/JwtUserSessionService.cs index 262f182..22e3505 100644 --- a/server/Infrastructure/Auth/JwtUserSessionService.cs +++ b/server/Infrastructure/Auth/JwtUserSessionService.cs @@ -29,8 +29,8 @@ TimeProvider timeProvider Id = Guid.NewGuid(), UserId = userId, Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)), - ExpiresAt = timeProvider.GetUtcNow().Add(settings.Value.RefreshTokenLifetime).DateTime, - CreatedAt = timeProvider.GetUtcNow().DateTime + ExpiresAt = timeProvider.GetUtcNow().Add(settings.Value.RefreshTokenLifetime), + CreatedAt = timeProvider.GetUtcNow() }; dbContext.RefreshTokens.Add(refreshToken); @@ -53,7 +53,7 @@ private string GenerateAccessToken(Guid userId) var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(claims), - Expires = timeProvider.GetUtcNow().Add(settings.Value.AccessTokenLifetime).DateTime, + Expires = timeProvider.GetUtcNow().Add(settings.Value.AccessTokenLifetime).UtcDateTime, SigningCredentials = credentials, Issuer = settings.Value.Issuer, Audience = settings.Value.Audience diff --git a/server/Infrastructure/Auth/RefreshToken.cs b/server/Infrastructure/Auth/RefreshToken.cs index 6b5cd54..445fafa 100644 --- a/server/Infrastructure/Auth/RefreshToken.cs +++ b/server/Infrastructure/Auth/RefreshToken.cs @@ -7,7 +7,7 @@ public sealed class RefreshToken public Guid Id { get; set; } public Guid UserId { get; set; } public string? Token { get; set; } - public DateTime ExpiresAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } public bool IsRevoked { get; set; } - public DateTime CreatedAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } } \ No newline at end of file From 4229629522156b5dd0a0a1d6437fa0b00590e342 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 22 Oct 2026 21:15:00 +0200 Subject: [PATCH 07/33] refactor: wrap token pair in custom record --- .../Users/Commands/ProvisionUser/IUserSessionService.cs | 2 +- .../Features/Users/Commands/ProvisionUser/JwtTokenPair.cs | 3 +++ .../Commands/ProvisionUser/ProvisionUserCommandHandler.cs | 4 ++-- server/Infrastructure/Auth/JwtUserSessionService.cs | 4 ++-- 4 files changed, 8 insertions(+), 5 deletions(-) create mode 100644 server/Application/Features/Users/Commands/ProvisionUser/JwtTokenPair.cs diff --git a/server/Application/Features/Users/Commands/ProvisionUser/IUserSessionService.cs b/server/Application/Features/Users/Commands/ProvisionUser/IUserSessionService.cs index a9da899..e03dbd7 100644 --- a/server/Application/Features/Users/Commands/ProvisionUser/IUserSessionService.cs +++ b/server/Application/Features/Users/Commands/ProvisionUser/IUserSessionService.cs @@ -4,5 +4,5 @@ namespace Application.Features.Users.Commands.ProvisionUser; public interface IUserSessionService { - (string AccessToken, string RefreshToken) CreateSession(Guid userId); + JwtTokenPair CreateSession(Guid userId); } \ No newline at end of file diff --git a/server/Application/Features/Users/Commands/ProvisionUser/JwtTokenPair.cs b/server/Application/Features/Users/Commands/ProvisionUser/JwtTokenPair.cs new file mode 100644 index 0000000..9c0b15c --- /dev/null +++ b/server/Application/Features/Users/Commands/ProvisionUser/JwtTokenPair.cs @@ -0,0 +1,3 @@ +namespace Application.Features.Users.Commands.ProvisionUser; + +public record JwtTokenPair(string AccessToken, string RefreshToken); \ No newline at end of file diff --git a/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs b/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs index c216736..e3f3c68 100644 --- a/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs +++ b/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs @@ -54,11 +54,11 @@ public async Task> ExecuteAsync(ProvisionUserCommand cmd, LogUserLoggedIn(user.Id); } - var (accessToken, refreshToken) = userSessionService.CreateSession(user.Id); + var tokens = userSessionService.CreateSession(user.Id); await unitOfWork.SaveChangesAsync(ct); - return Result.Ok(new LoginResponse(accessToken, refreshToken)); + return Result.Ok(new LoginResponse(tokens.AccessToken, tokens.RefreshToken)); } [LoggerMessage(LogLevel.Warning, "Provisioning aborted: USOS authentication failed. Error: {error}")] diff --git a/server/Infrastructure/Auth/JwtUserSessionService.cs b/server/Infrastructure/Auth/JwtUserSessionService.cs index 22e3505..d478fe3 100644 --- a/server/Infrastructure/Auth/JwtUserSessionService.cs +++ b/server/Infrastructure/Auth/JwtUserSessionService.cs @@ -20,7 +20,7 @@ public sealed class JwtUserSessionService( TimeProvider timeProvider ) : IUserSessionService { - public (string AccessToken, string RefreshToken) CreateSession(Guid userId) + public JwtTokenPair CreateSession(Guid userId) { var accessToken = GenerateAccessToken(userId); @@ -35,7 +35,7 @@ TimeProvider timeProvider dbContext.RefreshTokens.Add(refreshToken); - return (accessToken, refreshToken.Token); + return new JwtTokenPair(accessToken, refreshToken.Token); } private string GenerateAccessToken(Guid userId) From d69da73274265c04a5d10a3d6de3419097d8f0c5 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 22 Oct 2026 21:16:07 +0200 Subject: [PATCH 08/33] test(ProvisionUserCommandHandlerTests): check for tokens generation --- .../ProvisionUserCommandHandlerTests.cs | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTests.cs b/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTests.cs index 2332038..2a54968 100644 --- a/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTests.cs +++ b/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTests.cs @@ -6,9 +6,6 @@ using Application.Errors; using Application.Features.Users.Commands.ProvisionUser; using Application.Interfaces; -using Application.Mappings; - -using AutoMapper; using Domain.Users; @@ -73,10 +70,11 @@ public async Task ExecuteAsync_WhenUsosAuthenticationFails_ShouldReturnFailureRe _idHasherMock.Verify(x => x.Hash(It.IsAny()), Times.Never); _userRepositoryMock.Verify(x => x.Add(It.IsAny()), Times.Never); + _iUnitOfWorkMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Never); } [Fact] - public async Task ExecuteAsync_WhenUserAlreadyExistsInDb_ShouldReturnExistingUserWithoutCreatingNewOne() + public async Task ExecuteAsync_WhenUserAlreadyExistsInDb_ShouldReturnExistingUserAndCreateSession() { // Arrange var cmd = new ProvisionUserCommand("token", "verifier"); @@ -85,6 +83,7 @@ public async Task ExecuteAsync_WhenUserAlreadyExistsInDb_ShouldReturnExistingUse var fakeDate = new DateTimeOffset(2026, 6, 6, 12, 0, 0, TimeSpan.Zero); var existingUser = User.Create(hashedUsosId, fakeDate); var usosUserDto = new UsosUserDto(rawUsosId); + var fakeJwtTokenPair = new JwtTokenPair("ACCESS_TOKEN", "REFRESH_TOKEN"); _usosOAuthServiceMock .Setup(x => x.HandleCallbackAndGetUserAsync(cmd.OauthToken, cmd.OauthVerifier, It.IsAny())) @@ -98,18 +97,24 @@ public async Task ExecuteAsync_WhenUserAlreadyExistsInDb_ShouldReturnExistingUse .Setup(x => x.GetByHashedUsosIdAsync(hashedUsosId, It.IsAny())) .ReturnsAsync(existingUser); + _userSessionServiceMock + .Setup(x => x.CreateSession(existingUser.Id)) + .Returns(fakeJwtTokenPair); + // Act var result = await _sut.ExecuteAsync(cmd, CancellationToken.None); // Assert result.IsSuccess.Should().BeTrue(); + result.Value.AccessToken.Should().Be("ACCESS_TOKEN"); + result.Value.RefreshToken.Should().Be("REFRESH_TOKEN"); _userRepositoryMock.Verify(x => x.Add(It.IsAny()), Times.Never); - _iUnitOfWorkMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Never); + _iUnitOfWorkMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); } [Fact] - public async Task ExecuteAsync_WhenUserDoesNotExistInDb_ShouldCreateNewUserAndSaveToDb() + public async Task ExecuteAsync_WhenUserDoesNotExistInDb_ShouldCreateNewUserAndSaveAndReturnTokens() { // Arrange var cmd = new ProvisionUserCommand("token", "verifier"); @@ -117,6 +122,8 @@ public async Task ExecuteAsync_WhenUserDoesNotExistInDb_ShouldCreateNewUserAndSa var hashedUsosId = "ABC_HASHED_ID"; var usosUserDto = new UsosUserDto(rawUsosId); var fakeDate = new DateTimeOffset(2026, 6, 6, 12, 0, 0, TimeSpan.Zero); + var fakeJwtTokenPair = new JwtTokenPair("ACCESS_TOKEN", "REFRESH_TOKEN"); + _usosOAuthServiceMock .Setup(x => x.HandleCallbackAndGetUserAsync(cmd.OauthToken, cmd.OauthVerifier, It.IsAny())) .ReturnsAsync(Result.Ok(usosUserDto)); @@ -128,6 +135,11 @@ public async Task ExecuteAsync_WhenUserDoesNotExistInDb_ShouldCreateNewUserAndSa _userRepositoryMock .Setup(x => x.GetByHashedUsosIdAsync(hashedUsosId, It.IsAny())) .ReturnsAsync((User?)null); + + _userSessionServiceMock + .Setup(x => x.CreateSession(It.IsAny())) + .Returns(fakeJwtTokenPair); + _fakeTimeProvider.SetUtcNow(fakeDate); // Act @@ -136,6 +148,11 @@ public async Task ExecuteAsync_WhenUserDoesNotExistInDb_ShouldCreateNewUserAndSa // Assert result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBeNull(); + result.Value.AccessToken.Should().Be("ACCESS_TOKEN"); + result.Value.RefreshToken.Should().Be("REFRESH_TOKEN"); + + _idHasherMock.Verify(x => x.Hash(rawUsosId), Times.Once); + _userRepositoryMock.Verify(x => x.GetByHashedUsosIdAsync(hashedUsosId, It.IsAny()), Times.Once); _userRepositoryMock.Verify(x => x.Add(It.Is(u => u.HashedUsosId == hashedUsosId && u.JoinedDate == fakeDate)), Times.Once); _iUnitOfWorkMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); From 28e75c5d754c46b732152edc51d55505bbadfbf8 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 22 Oct 2026 21:38:39 +0200 Subject: [PATCH 09/33] refactor(PresentationConfiguration.cs): inject IOptions to use typesafe jwt config --- .../Extensions/PresentationConfiguration.cs | 27 ++++++++++++------- server/Presentation/Program.cs | 2 +- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/server/Presentation/Extensions/PresentationConfiguration.cs b/server/Presentation/Extensions/PresentationConfiguration.cs index 06a134d..e1beec8 100644 --- a/server/Presentation/Extensions/PresentationConfiguration.cs +++ b/server/Presentation/Extensions/PresentationConfiguration.cs @@ -3,13 +3,15 @@ using System.Text.Json; using System.Threading.Tasks; +using Infrastructure.Auth.Configuration; + using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi; @@ -68,12 +70,13 @@ private static void AddPutWikiOpenApi(this IServiceCollection services) }); } - private static void AddPutWikiAuthentication(this IServiceCollection services, IConfiguration configuration) + private static void AddPutWikiAuthentication(this IServiceCollection services) { - services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => + services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure>((options, jwtOptions) => { + var settings = jwtOptions.Value; + options.MapInboundClaims = false; options.TokenValidationParameters = new TokenValidationParameters { @@ -81,14 +84,18 @@ private static void AddPutWikiAuthentication(this IServiceCollection services, I ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, - ValidIssuer = configuration["Jwt:Issuer"], - ValidAudience = configuration["Jwt:Audience"], + ValidIssuer = settings.Issuer, + ValidAudience = settings.Audience, IssuerSigningKey = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"]!)), + Encoding.UTF8.GetBytes(settings.SecretKey)), ClockSkew = TimeSpan.Zero }; }); + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); + services.AddAuthorization(); } @@ -115,7 +122,7 @@ public static IApplicationBuilder UsePutWikiOpenApiDocs(this WebApplication app) return app; } - public static IServiceCollection AddPresentation(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddPresentation(this IServiceCollection services) { services.AddControllers(opts => opts.Conventions.Add( @@ -140,7 +147,7 @@ public static IServiceCollection AddPresentation(this IServiceCollection service }); services.AddPutWikiOpenApi(); - services.AddPutWikiAuthentication(configuration); + services.AddPutWikiAuthentication(); return services; } diff --git a/server/Presentation/Program.cs b/server/Presentation/Program.cs index b6a331c..f71e981 100644 --- a/server/Presentation/Program.cs +++ b/server/Presentation/Program.cs @@ -24,8 +24,8 @@ builder.Services.AddExceptionHandler(); builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddPresentation(); builder.Services.AddApplication(); -builder.Services.AddPresentation(builder.Configuration); var app = builder.Build(); From cd08f945fc2af0dd48b09408ef51f6a9a8b6daa3 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 22 Oct 2026 22:56:50 +0200 Subject: [PATCH 10/33] feat(RefreshToken): configure new entity in ef core --- server/Infrastructure/AppDbContext.cs | 4 +--- .../RefreshTokenEntityTypeConfiguration.cs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 server/Infrastructure/EntitiesConfiguration/RefreshTokenEntityTypeConfiguration.cs diff --git a/server/Infrastructure/AppDbContext.cs b/server/Infrastructure/AppDbContext.cs index 5bc1937..e191569 100644 --- a/server/Infrastructure/AppDbContext.cs +++ b/server/Infrastructure/AppDbContext.cs @@ -25,8 +25,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) new UserEntityTypeConfiguration().Configure(modelBuilder.Entity()); new AcademicTeacherEntityTypeConfiguration().Configure(modelBuilder.Entity()); - - modelBuilder.Entity() - .HasIndex(b => b.Token); + new RefreshTokenEntityTypeConfiguration().Configure(modelBuilder.Entity()); } } \ No newline at end of file diff --git a/server/Infrastructure/EntitiesConfiguration/RefreshTokenEntityTypeConfiguration.cs b/server/Infrastructure/EntitiesConfiguration/RefreshTokenEntityTypeConfiguration.cs new file mode 100644 index 0000000..bec02f8 --- /dev/null +++ b/server/Infrastructure/EntitiesConfiguration/RefreshTokenEntityTypeConfiguration.cs @@ -0,0 +1,19 @@ +using Infrastructure.Auth; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.EntitiesConfiguration; + +public class RefreshTokenEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.Token).IsRequired(); + builder.HasIndex(x => x.Token).IsUnique(); + + builder.HasIndex(x => x.UserId); + } +} \ No newline at end of file From 2a8d619b9c78b36069115ab99904f0f8aa76da79 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 22 Oct 2026 22:57:16 +0200 Subject: [PATCH 11/33] feat: add new migration --- ...22205450_AddRefreshTokenEntity.Designer.cs | 171 ++++++++++++++++++ .../20261022205450_AddRefreshTokenEntity.cs | 50 +++++ .../Migrations/AppDbContextModelSnapshot.cs | 41 +++++ 3 files changed, 262 insertions(+) create mode 100644 server/Infrastructure/Migrations/20261022205450_AddRefreshTokenEntity.Designer.cs create mode 100644 server/Infrastructure/Migrations/20261022205450_AddRefreshTokenEntity.cs diff --git a/server/Infrastructure/Migrations/20261022205450_AddRefreshTokenEntity.Designer.cs b/server/Infrastructure/Migrations/20261022205450_AddRefreshTokenEntity.Designer.cs new file mode 100644 index 0000000..de20ecf --- /dev/null +++ b/server/Infrastructure/Migrations/20261022205450_AddRefreshTokenEntity.Designer.cs @@ -0,0 +1,171 @@ +// +using System; +using Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261022205450_AddRefreshTokenEntity")] + partial class AddRefreshTokenEntity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.AcademicTeachers.AcademicTeacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.PrimitiveCollection("Degrees") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("degrees"); + + b.Property("Description") + .HasMaxLength(2500) + .HasColumnType("character varying(2500)") + .HasColumnName("description"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("email"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PhoneNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("phone_number"); + + b.Property("PhotoUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("photo_url"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("slug"); + + b.Property("UsosId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("usos_id"); + + b.Property("WebsiteUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("website_url"); + + b.HasKey("Id") + .HasName("pk_academic_teachers"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_academic_teachers_email"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_academic_teachers_slug"); + + b.HasIndex("UsosId") + .IsUnique() + .HasDatabaseName("ix_academic_teachers_usos_id"); + + b.ToTable("academic_teachers", (string)null); + }); + + modelBuilder.Entity("Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HashedUsosId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hashed_usos_id"); + + b.Property("JoinedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_date"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("HashedUsosId") + .IsUnique() + .HasDatabaseName("ix_users_hashed_usos_id"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Infrastructure.Auth.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IsRevoked") + .HasColumnType("boolean") + .HasColumnName("is_revoked"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("Token") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/Infrastructure/Migrations/20261022205450_AddRefreshTokenEntity.cs b/server/Infrastructure/Migrations/20261022205450_AddRefreshTokenEntity.cs new file mode 100644 index 0000000..aacf54c --- /dev/null +++ b/server/Infrastructure/Migrations/20261022205450_AddRefreshTokenEntity.cs @@ -0,0 +1,50 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class AddRefreshTokenEntity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "refresh_tokens", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + token = table.Column(type: "text", nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: false), + is_revoked = table.Column(type: "boolean", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_refresh_tokens", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_refresh_tokens_token", + table: "refresh_tokens", + column: "token", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_refresh_tokens_user_id", + table: "refresh_tokens", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "refresh_tokens"); + } + } +} \ No newline at end of file diff --git a/server/Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/server/Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 3facbb6..b5f7502 100644 --- a/server/Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/server/Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -132,6 +132,47 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("users", (string)null); }); + + modelBuilder.Entity("Infrastructure.Auth.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IsRevoked") + .HasColumnType("boolean") + .HasColumnName("is_revoked"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("Token") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); #pragma warning restore 612, 618 } } From 4ae36d8efe2b6b893f8b826144fef170aae4af4f Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 22 Oct 2026 22:59:48 +0200 Subject: [PATCH 12/33] refactor: rename public api dto to Response suffix --- .../{AcademicTeacherDto.cs => AcademicTeacherResponse.cs} | 2 +- .../Queries/GetAcademicTeacherQueryHandler.cs | 2 +- .../AcademicTeachers/Queries/IAcademicTeacherQueryService.cs | 2 +- server/Infrastructure/Services/AcademicTeacherQueryService.cs | 4 ++-- server/Presentation/Controllers/AcademicTeachersController.cs | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) rename server/Application/Features/AcademicTeachers/Queries/{AcademicTeacherDto.cs => AcademicTeacherResponse.cs} (91%) diff --git a/server/Application/Features/AcademicTeachers/Queries/AcademicTeacherDto.cs b/server/Application/Features/AcademicTeachers/Queries/AcademicTeacherResponse.cs similarity index 91% rename from server/Application/Features/AcademicTeachers/Queries/AcademicTeacherDto.cs rename to server/Application/Features/AcademicTeachers/Queries/AcademicTeacherResponse.cs index bb05434..ab63d9d 100644 --- a/server/Application/Features/AcademicTeachers/Queries/AcademicTeacherDto.cs +++ b/server/Application/Features/AcademicTeachers/Queries/AcademicTeacherResponse.cs @@ -1,6 +1,6 @@ namespace Application.Features.AcademicTeachers.Queries; -public record AcademicTeacherDto( +public record AcademicTeacherResponse( string Id, string Slug, AcademicTeacherBaseInfoDto BaseInfo, diff --git a/server/Application/Features/AcademicTeachers/Queries/GetAcademicTeacherQueryHandler.cs b/server/Application/Features/AcademicTeachers/Queries/GetAcademicTeacherQueryHandler.cs index 471b944..118f24d 100644 --- a/server/Application/Features/AcademicTeachers/Queries/GetAcademicTeacherQueryHandler.cs +++ b/server/Application/Features/AcademicTeachers/Queries/GetAcademicTeacherQueryHandler.cs @@ -9,7 +9,7 @@ namespace Application.Features.AcademicTeachers.Queries; public class GetAcademicTeacherQueryHandler(IAcademicTeacherQueryService queryService) { - public async Task> ExecuteAsync(GetAcademicTeacherQuery query, + public async Task> ExecuteAsync(GetAcademicTeacherQuery query, CancellationToken ct = default) { var teacherDto = await queryService.GetAcademicTeacherByIdAsync(query.Id, ct); diff --git a/server/Application/Features/AcademicTeachers/Queries/IAcademicTeacherQueryService.cs b/server/Application/Features/AcademicTeachers/Queries/IAcademicTeacherQueryService.cs index b2ecc1f..c3fc85c 100644 --- a/server/Application/Features/AcademicTeachers/Queries/IAcademicTeacherQueryService.cs +++ b/server/Application/Features/AcademicTeachers/Queries/IAcademicTeacherQueryService.cs @@ -6,5 +6,5 @@ namespace Application.Features.AcademicTeachers.Queries; public interface IAcademicTeacherQueryService { - Task GetAcademicTeacherByIdAsync(Guid id, CancellationToken ct = default); + Task GetAcademicTeacherByIdAsync(Guid id, CancellationToken ct = default); } \ No newline at end of file diff --git a/server/Infrastructure/Services/AcademicTeacherQueryService.cs b/server/Infrastructure/Services/AcademicTeacherQueryService.cs index 8542fd4..245a5f9 100644 --- a/server/Infrastructure/Services/AcademicTeacherQueryService.cs +++ b/server/Infrastructure/Services/AcademicTeacherQueryService.cs @@ -11,12 +11,12 @@ namespace Infrastructure.Services; public class AcademicTeacherQueryService(AppDbContext context) : IAcademicTeacherQueryService { - public async Task GetAcademicTeacherByIdAsync(Guid id, CancellationToken ct = default) + public async Task GetAcademicTeacherByIdAsync(Guid id, CancellationToken ct = default) { return await context.AcademicTeachers .AsNoTracking() .Where(t => t.Id == id) - .Select(t => new AcademicTeacherDto( + .Select(t => new AcademicTeacherResponse( t.Id.ToString(), t.Slug.ToString(), new AcademicTeacherBaseInfoDto( diff --git a/server/Presentation/Controllers/AcademicTeachersController.cs b/server/Presentation/Controllers/AcademicTeachersController.cs index bb76d28..3c5c8f3 100644 --- a/server/Presentation/Controllers/AcademicTeachersController.cs +++ b/server/Presentation/Controllers/AcademicTeachersController.cs @@ -16,7 +16,7 @@ public class AcademicTeachersController( GetAcademicTeacherQueryHandler getAcademicTeacherQueryHandler) : BaseApiController { [HttpGet("{id}")] - [ProducesResponseType(typeof(AcademicTeacherDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AcademicTeacherResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetAcademicTeacher(string id, CancellationToken ct) { From 476fb9161213fcbb2cf87d41740222644a6c46fa Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 22 Oct 2026 23:10:56 +0200 Subject: [PATCH 13/33] refactor: change naming convention for unit tests to Test suffix --- server/.editorconfig | 2 +- ...mandHandlerTests.cs => ProvisionUserCommandHandlerTest.cs} | 4 ++-- ...AcademicTeacherSlugTests.cs => AcademicTeacherSlugTest.cs} | 2 +- .../{AcademicTeacherTests.cs => AcademicTeacherTest.cs} | 2 +- .../{HmacUsosIdHasherTests.cs => HmacUsosIdHasherTest.cs} | 2 +- .../Infrastructure/Auth/{OAuth1Tests.cs => OAuth1Test.cs} | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) rename server/PutWiki.UnitTests/Application/{ProvisionUserCommandHandlerTests.cs => ProvisionUserCommandHandlerTest.cs} (98%) rename server/PutWiki.UnitTests/Domain/AcademicTeachers/{AcademicTeacherSlugTests.cs => AcademicTeacherSlugTest.cs} (99%) rename server/PutWiki.UnitTests/Domain/AcademicTeachers/{AcademicTeacherTests.cs => AcademicTeacherTest.cs} (99%) rename server/PutWiki.UnitTests/Infrastructure/Auth/{HmacUsosIdHasherTests.cs => HmacUsosIdHasherTest.cs} (98%) rename server/PutWiki.UnitTests/Infrastructure/Auth/{OAuth1Tests.cs => OAuth1Test.cs} (98%) diff --git a/server/.editorconfig b/server/.editorconfig index 04549e6..2104d83 100644 --- a/server/.editorconfig +++ b/server/.editorconfig @@ -396,7 +396,7 @@ dotnet_diagnostic.S3400.severity = silent dotnet_diagnostic.S1135.severity = suggestion # --- Naming convention rules for unit tests --- -[**/*Tests.cs] +[**/*Test.cs] dotnet_diagnostic.CA1707.severity = none # E.g. _Should_When diff --git a/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTests.cs b/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTest.cs similarity index 98% rename from server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTests.cs rename to server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTest.cs index 2a54968..9677206 100644 --- a/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTests.cs +++ b/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTest.cs @@ -20,7 +20,7 @@ namespace PutWiki.UnitTests.Application; -public class ProvisionUserCommandHandlerTests +public class ProvisionUserCommandHandlerTest { private readonly Mock _usosOAuthServiceMock; private readonly Mock _idHasherMock; @@ -30,7 +30,7 @@ public class ProvisionUserCommandHandlerTests private readonly Mock _userSessionServiceMock; private readonly ProvisionUserCommandHandler _sut; - public ProvisionUserCommandHandlerTests() + public ProvisionUserCommandHandlerTest() { _usosOAuthServiceMock = new Mock(); _idHasherMock = new Mock(); diff --git a/server/PutWiki.UnitTests/Domain/AcademicTeachers/AcademicTeacherSlugTests.cs b/server/PutWiki.UnitTests/Domain/AcademicTeachers/AcademicTeacherSlugTest.cs similarity index 99% rename from server/PutWiki.UnitTests/Domain/AcademicTeachers/AcademicTeacherSlugTests.cs rename to server/PutWiki.UnitTests/Domain/AcademicTeachers/AcademicTeacherSlugTest.cs index e78524c..937012d 100644 --- a/server/PutWiki.UnitTests/Domain/AcademicTeachers/AcademicTeacherSlugTests.cs +++ b/server/PutWiki.UnitTests/Domain/AcademicTeachers/AcademicTeacherSlugTest.cs @@ -6,7 +6,7 @@ namespace PutWiki.UnitTests.Domain.AcademicTeachers; -public class AcademicTeacherSlugTests +public class AcademicTeacherSlugTest { [Theory] [InlineData("Jan Kowalski", "uSOs-1001", "jan-kowalski-usos-1001")] diff --git a/server/PutWiki.UnitTests/Domain/AcademicTeachers/AcademicTeacherTests.cs b/server/PutWiki.UnitTests/Domain/AcademicTeachers/AcademicTeacherTest.cs similarity index 99% rename from server/PutWiki.UnitTests/Domain/AcademicTeachers/AcademicTeacherTests.cs rename to server/PutWiki.UnitTests/Domain/AcademicTeachers/AcademicTeacherTest.cs index 9f28305..fc90ca6 100644 --- a/server/PutWiki.UnitTests/Domain/AcademicTeachers/AcademicTeacherTests.cs +++ b/server/PutWiki.UnitTests/Domain/AcademicTeachers/AcademicTeacherTest.cs @@ -7,7 +7,7 @@ namespace PutWiki.UnitTests.Domain.AcademicTeachers; -public class AcademicTeacherTests +public class AcademicTeacherTest { private static readonly string[] DefaultValidDegrees = ["dr hab.", "inż."]; diff --git a/server/PutWiki.UnitTests/Infrastructure/Auth/HmacUsosIdHasherTests.cs b/server/PutWiki.UnitTests/Infrastructure/Auth/HmacUsosIdHasherTest.cs similarity index 98% rename from server/PutWiki.UnitTests/Infrastructure/Auth/HmacUsosIdHasherTests.cs rename to server/PutWiki.UnitTests/Infrastructure/Auth/HmacUsosIdHasherTest.cs index b050d2e..05e2203 100644 --- a/server/PutWiki.UnitTests/Infrastructure/Auth/HmacUsosIdHasherTests.cs +++ b/server/PutWiki.UnitTests/Infrastructure/Auth/HmacUsosIdHasherTest.cs @@ -7,7 +7,7 @@ namespace PutWiki.UnitTests.Infrastructure.Auth; -public class HmacUsosIdHasherTests +public class HmacUsosIdHasherTest { private const string TestKey = "secret-hasher-key"; diff --git a/server/PutWiki.UnitTests/Infrastructure/Auth/OAuth1Tests.cs b/server/PutWiki.UnitTests/Infrastructure/Auth/OAuth1Test.cs similarity index 98% rename from server/PutWiki.UnitTests/Infrastructure/Auth/OAuth1Tests.cs rename to server/PutWiki.UnitTests/Infrastructure/Auth/OAuth1Test.cs index f6c9ed5..4e63241 100644 --- a/server/PutWiki.UnitTests/Infrastructure/Auth/OAuth1Tests.cs +++ b/server/PutWiki.UnitTests/Infrastructure/Auth/OAuth1Test.cs @@ -6,7 +6,7 @@ namespace PutWiki.UnitTests.Infrastructure.Auth; -public class OAuth1Tests +public class OAuth1Test { [Theory] [InlineData("Ladies + Gentlemen", "Ladies%20%2B%20Gentlemen")] From 65ba1354a38703193dd994b01b47aa0006a4f03b Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 22 Oct 2026 23:20:24 +0200 Subject: [PATCH 14/33] refactor: add stricter rules for keys in validators --- .../Auth/Configuration/JwtTokensSettingsValidator.cs | 5 +++++ .../Auth/Configuration/UsosOAuthSettingsValidator.cs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/server/Infrastructure/Auth/Configuration/JwtTokensSettingsValidator.cs b/server/Infrastructure/Auth/Configuration/JwtTokensSettingsValidator.cs index 8992595..71b51a5 100644 --- a/server/Infrastructure/Auth/Configuration/JwtTokensSettingsValidator.cs +++ b/server/Infrastructure/Auth/Configuration/JwtTokensSettingsValidator.cs @@ -17,5 +17,10 @@ public JwtTokensSettingsValidator() RuleFor(x => x.SecretKey) .NotEmpty() .WithMessage("Missing jwt signing key."); + + RuleFor(x => x.SecretKey) + .MinimumLength(32) + .Matches("[a-zA-Z0-9]") + .WithMessage("Jwt signing key should be at least 32 ASCII characters (bytes) long."); } } \ No newline at end of file diff --git a/server/Infrastructure/Auth/Configuration/UsosOAuthSettingsValidator.cs b/server/Infrastructure/Auth/Configuration/UsosOAuthSettingsValidator.cs index 0462758..eb12df3 100644 --- a/server/Infrastructure/Auth/Configuration/UsosOAuthSettingsValidator.cs +++ b/server/Infrastructure/Auth/Configuration/UsosOAuthSettingsValidator.cs @@ -34,5 +34,10 @@ public UsosOAuthSettingsValidator() RuleFor(x => x.HashingKey) .NotEmpty() .WithMessage("Missing user's usos id hashing key."); + + RuleFor(x => x.HashingKey) + .Length(64) + .Matches("[a-zA-Z0-9]") + .WithMessage("Hashing key should be exactly 64 ASCII characters (bytes) long."); } } \ No newline at end of file From 96c48823fd9ec7124b6c650be9b967b2d7d54450 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Fri, 23 Oct 2026 00:05:18 +0200 Subject: [PATCH 15/33] test: add unit tests for JwtUserSessionService --- server/Infrastructure/AppDbContext.cs | 3 +- .../Extensions/PresentationConfiguration.cs | 9 +- .../Auth/JwtUserSessionServiceTest.cs | 210 ++++++++++++++++++ 3 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs diff --git a/server/Infrastructure/AppDbContext.cs b/server/Infrastructure/AppDbContext.cs index e191569..cbbf8ba 100644 --- a/server/Infrastructure/AppDbContext.cs +++ b/server/Infrastructure/AppDbContext.cs @@ -14,8 +14,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op { public required DbSet Users { get; set; } public required DbSet AcademicTeachers { get; set; } - public required DbSet RefreshTokens { get; set; } - + public virtual required DbSet RefreshTokens { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/server/Presentation/Extensions/PresentationConfiguration.cs b/server/Presentation/Extensions/PresentationConfiguration.cs index e1beec8..cd0b2ae 100644 --- a/server/Presentation/Extensions/PresentationConfiguration.cs +++ b/server/Presentation/Extensions/PresentationConfiguration.cs @@ -81,13 +81,16 @@ private static void AddPutWikiAuthentication(this IServiceCollection services) options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, ValidIssuer = settings.Issuer, + + ValidateAudience = true, ValidAudience = settings.Audience, + + ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(settings.SecretKey)), + + ValidateLifetime = true, ClockSkew = TimeSpan.Zero }; }); diff --git a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs new file mode 100644 index 0000000..a387952 --- /dev/null +++ b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using FluentAssertions; + +using Infrastructure; +using Infrastructure.Auth; +using Infrastructure.Auth.Configuration; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using Microsoft.IdentityModel.JsonWebTokens; + +using Moq; + +namespace PutWiki.UnitTests.Infrastructure.Auth; + +public class JwtUserSessionServiceTest +{ + private const string ValidSecretKey = "secret-key-that-is-32-bytes-long"; + private const string ValidIssuer = "putwiki-api"; + private const string ValidAudience = "putwiki-frontend"; + + private readonly Mock> _dbSetMock; + private readonly Mock _dbContextMock; + private readonly FakeTimeProvider _fakeTimeProvider; + private readonly List _refreshTokenStore; + + public JwtUserSessionServiceTest() + { + _dbSetMock = new Mock>(); + _fakeTimeProvider = new FakeTimeProvider(); + _refreshTokenStore = new List(); + + var dbContextOptions = new DbContextOptionsBuilder().Options; + _dbContextMock = new Mock(dbContextOptions); + + _dbSetMock + .Setup(x => x.Add(It.IsAny())) + .Callback(x => _refreshTokenStore.Add(x)); + + _dbContextMock + .Setup(x => x.RefreshTokens) + .Returns(_dbSetMock.Object); + } + + private static IOptions CreateOptions( + string secretKey = ValidSecretKey, + string issuer = ValidIssuer, + string audience = ValidAudience, + TimeSpan? accessTokenLifetime = null, + TimeSpan? refreshTokenLifetime = null) + { + var settings = new JwtSettings + { + SecretKey = secretKey, + Issuer = issuer, + Audience = audience, + AccessTokenLifetime = accessTokenLifetime ?? TimeSpan.FromMinutes(30), + RefreshTokenLifetime = refreshTokenLifetime ?? TimeSpan.FromDays(7) + }; + + return Options.Create(settings); + } + + [Fact] + public void CreateSession_GivenValidUserId_ReturnsValidJwtAndRefreshTokenPair() + { + // Arrange + var userId = Guid.NewGuid(); + var options = CreateOptions(); + var sut = new JwtUserSessionService(options, _dbContextMock.Object, _fakeTimeProvider); + + // Act + var result = sut.CreateSession(userId); + + // Assert + result.Should().NotBeNull(); + result.AccessToken.Should().NotBeNullOrWhiteSpace(); + result.RefreshToken.Should().NotBeNullOrWhiteSpace(); + + var handler = new JsonWebTokenHandler(); + var jwtToken = handler.ReadJsonWebToken(result.AccessToken); + jwtToken.Subject.Should().Be(userId.ToString()); + } + + [Fact] + public void CreateSession_GivenValidUserId_PersistsRefreshToken() + { + // Arrange + var userId = Guid.NewGuid(); + var options = CreateOptions(); + var sut = new JwtUserSessionService(options, _dbContextMock.Object, _fakeTimeProvider); + + // Act + var result = sut.CreateSession(userId); + + // Assert + _dbSetMock.Verify(x => x.Add(It.Is(rt => + rt.UserId == userId && + rt.Token == result.RefreshToken && + rt.Id != Guid.Empty + )), Times.Once); + } + + [Fact] + public void CreateSession_GivenConfiguredLifetimes_SetsCorrectAccessTokenExpirationTime() + { + // Arrange + var userId = Guid.NewGuid(); + var accessTokenLifetime = TimeSpan.FromMinutes(45); + var options = CreateOptions(accessTokenLifetime: accessTokenLifetime); + + var fixedTime = new DateTimeOffset(2026, 10, 22, 23, 0, 0, TimeSpan.Zero); + _fakeTimeProvider.SetUtcNow(fixedTime); + + var sut = new JwtUserSessionService(options, _dbContextMock.Object, _fakeTimeProvider); + + // Act + var result = sut.CreateSession(userId); + + // Assert + var handler = new JsonWebTokenHandler(); + var jwtToken = handler.ReadJsonWebToken(result.AccessToken); + + var expectedExpiration = fixedTime.Add(accessTokenLifetime).UtcDateTime; + jwtToken.ValidTo.Should().BeCloseTo(expectedExpiration, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void CreateSession_GivenConfiguredLifetimes_SetsCorrectRefreshTokenExpirationAndCreationDates() + { + // Arrange + var userId = Guid.NewGuid(); + var refreshTokenLifetime = TimeSpan.FromDays(14); + var options = CreateOptions(refreshTokenLifetime: refreshTokenLifetime); + + var fixedTime = new DateTimeOffset(2026, 10, 22, 23, 0, 0, TimeSpan.Zero); + _fakeTimeProvider.SetUtcNow(fixedTime); + + var sut = new JwtUserSessionService(options, _dbContextMock.Object, _fakeTimeProvider); + + // Act + sut.CreateSession(userId); + + // Assert + var savedRefreshToken = _refreshTokenStore.Single(rt => rt.UserId == userId); + savedRefreshToken.CreatedAt.Should().Be(fixedTime); + savedRefreshToken.ExpiresAt.Should().Be(fixedTime.Add(refreshTokenLifetime)); + } + + [Fact] + public void CreateSession_WhenCalledMultipleTimesForSameUser_GeneratesUniqueRefreshTokens() + { + // Arrange + var userId = Guid.NewGuid(); + var options = CreateOptions(); + var sut = new JwtUserSessionService(options, _dbContextMock.Object, _fakeTimeProvider); + + // Act + var session1 = sut.CreateSession(userId); + var session2 = sut.CreateSession(userId); + + // Assert + session1.RefreshToken.Should().NotBe(session2.RefreshToken); + } + + [Fact] + public void CreateSession_WhenCalledMultipleTimesForSameUser_StoresAllRefreshTokensNotOverwritesThem() + { + // Arrange + var userId = Guid.NewGuid(); + var options = CreateOptions(); + var sut = new JwtUserSessionService(options, _dbContextMock.Object, _fakeTimeProvider); + + // Act + sut.CreateSession(userId); + sut.CreateSession(userId); + + // Assert + _dbSetMock.Verify(x => x.Add(It.Is(rt => rt.UserId == userId)), Times.Exactly(2)); + _refreshTokenStore.Where(rt => rt.UserId == userId).Should().HaveCount(2); + _refreshTokenStore[0].Id.Should().NotBe(_refreshTokenStore[1].Id); + } + + [Fact] + public void CreateSession_WhenConfiguredWithIssuerAndAudience_EmbedsClaimsInJwt() + { + // Arrange + var userId = Guid.NewGuid(); + var expectedIssuer = "custom issuer"; + var expectedAudience = "custom audience"; + var options = CreateOptions(issuer: expectedIssuer, audience: expectedAudience); + + var sut = new JwtUserSessionService(options, _dbContextMock.Object, _fakeTimeProvider); + + // Act + var result = sut.CreateSession(userId); + + // Assert + var handler = new JsonWebTokenHandler(); + var jwtToken = handler.ReadJsonWebToken(result.AccessToken); + + jwtToken.Issuer.Should().Be(expectedIssuer); + jwtToken.Audiences.Should().Contain(expectedAudience); + } + +} \ No newline at end of file From 2bea6c350a3f9800493bb9369e0cb23301e90f69 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Sat, 24 Oct 2026 15:39:26 +0200 Subject: [PATCH 16/33] feat: add accessor to user claims from jwt in api request --- .../{Interfaces => Abstractions}/IUnitOfWork.cs | 2 +- server/Application/Abstractions/IUserContext.cs | 9 +++++++++ .../ProvisionUserCommandHandler.cs | 2 +- server/Infrastructure/AppDbContext.cs | 2 +- server/Infrastructure/Auth/UserContext.cs | 17 +++++++++++++++++ .../Extensions/InfrastructureConfiguration.cs | 5 ++++- .../ProvisionUserCommandHandlerTest.cs | 2 +- 7 files changed, 34 insertions(+), 5 deletions(-) rename server/Application/{Interfaces => Abstractions}/IUnitOfWork.cs (82%) create mode 100644 server/Application/Abstractions/IUserContext.cs create mode 100644 server/Infrastructure/Auth/UserContext.cs diff --git a/server/Application/Interfaces/IUnitOfWork.cs b/server/Application/Abstractions/IUnitOfWork.cs similarity index 82% rename from server/Application/Interfaces/IUnitOfWork.cs rename to server/Application/Abstractions/IUnitOfWork.cs index 466ea95..2a77901 100644 --- a/server/Application/Interfaces/IUnitOfWork.cs +++ b/server/Application/Abstractions/IUnitOfWork.cs @@ -1,7 +1,7 @@ using System.Threading; using System.Threading.Tasks; -namespace Application.Interfaces; +namespace Application.Abstractions; public interface IUnitOfWork { diff --git a/server/Application/Abstractions/IUserContext.cs b/server/Application/Abstractions/IUserContext.cs new file mode 100644 index 0000000..47b2a2d --- /dev/null +++ b/server/Application/Abstractions/IUserContext.cs @@ -0,0 +1,9 @@ +using System; + +namespace Application.Abstractions; + +public interface IUserContext +{ + bool IsAuthenticated { get; } + Guid UserId { get; } +} \ No newline at end of file diff --git a/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs b/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs index e3f3c68..c9e8df6 100644 --- a/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs +++ b/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs @@ -2,9 +2,9 @@ using System.Threading; using System.Threading.Tasks; +using Application.Abstractions; using Application.Auth; using Application.Errors; -using Application.Interfaces; using Domain.Users; diff --git a/server/Infrastructure/AppDbContext.cs b/server/Infrastructure/AppDbContext.cs index cbbf8ba..0790334 100644 --- a/server/Infrastructure/AppDbContext.cs +++ b/server/Infrastructure/AppDbContext.cs @@ -1,4 +1,4 @@ -using Application.Interfaces; +using Application.Abstractions; using Domain.AcademicTeachers; using Domain.Users; diff --git a/server/Infrastructure/Auth/UserContext.cs b/server/Infrastructure/Auth/UserContext.cs new file mode 100644 index 0000000..00af57e --- /dev/null +++ b/server/Infrastructure/Auth/UserContext.cs @@ -0,0 +1,17 @@ +using System; + +using Application.Abstractions; + +using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.JsonWebTokens; + +namespace Infrastructure.Auth; + +public class UserContext(IHttpContextAccessor httpContextAccessor) : IUserContext +{ + public bool IsAuthenticated => httpContextAccessor.HttpContext?.User.Identity?.IsAuthenticated ?? false; + + public Guid UserId => + Guid.Parse(httpContextAccessor.HttpContext?.User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value ?? + throw new UnauthorizedAccessException()); +} \ No newline at end of file diff --git a/server/Infrastructure/Extensions/InfrastructureConfiguration.cs b/server/Infrastructure/Extensions/InfrastructureConfiguration.cs index d2257bc..4fc2a73 100644 --- a/server/Infrastructure/Extensions/InfrastructureConfiguration.cs +++ b/server/Infrastructure/Extensions/InfrastructureConfiguration.cs @@ -2,10 +2,10 @@ using System.Linq; using System.Threading.Tasks; +using Application.Abstractions; using Application.Auth; using Application.Features.AcademicTeachers.Queries; using Application.Features.Users.Commands.ProvisionUser; -using Application.Interfaces; using Domain.AcademicTeachers; using Domain.Users; @@ -73,6 +73,9 @@ private static void AddJwtAuth(this IServiceCollection services, IConfiguration .ValidateOnStart(); services.AddScoped(); + + services.AddHttpContextAccessor(); + services.AddScoped(); } private static void AddUsosOAuth(this IServiceCollection services, IConfiguration configuration) diff --git a/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTest.cs b/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTest.cs index 9677206..1671fc8 100644 --- a/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTest.cs +++ b/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTest.cs @@ -2,10 +2,10 @@ using System.Threading; using System.Threading.Tasks; +using Application.Abstractions; using Application.Auth; using Application.Errors; using Application.Features.Users.Commands.ProvisionUser; -using Application.Interfaces; using Domain.Users; From a50d37ae435799b001112cb1db9e7754b8c2b6b4 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Sat, 24 Oct 2026 15:42:06 +0200 Subject: [PATCH 17/33] refactor(AuthController): set jwt pair in cookies instead of bearer header --- .../Controllers/AuthController.cs | 35 +++++++++++++++++-- .../Extensions/PresentationConfiguration.cs | 13 +++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/server/Presentation/Controllers/AuthController.cs b/server/Presentation/Controllers/AuthController.cs index deb8226..0caa029 100644 --- a/server/Presentation/Controllers/AuthController.cs +++ b/server/Presentation/Controllers/AuthController.cs @@ -1,17 +1,26 @@ +using System; using System.Threading; using System.Threading.Tasks; using Application.Auth; using Application.Features.Users.Commands.ProvisionUser; +using FluentResults; + +using Infrastructure.Auth.Configuration; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; namespace Presentation.Controllers; public class AuthController( IUsosOAuthService usosOAuthService, - ProvisionUserCommandHandler provisionUserCommandHandler) : BaseApiController + ProvisionUserCommandHandler provisionUserCommandHandler, + IOptions jwtSettings, + IHostEnvironment environment) : BaseApiController { [HttpGet("login")] @@ -40,6 +49,28 @@ CancellationToken ct var command = new ProvisionUserCommand(oauthToken, oauthVerifier); var result = await provisionUserCommandHandler.ExecuteAsync(command, ct); - return HandleResult(result); + var accessTokenOptions = new CookieOptions + { + HttpOnly = true, + Secure = environment.IsProduction(), + SameSite = SameSiteMode.Strict, + IsEssential = true, + Expires = DateTimeOffset.UtcNow.Add(jwtSettings.Value.AccessTokenLifetime) + }; + + var refreshTokenOptions = new CookieOptions + { + HttpOnly = true, + Secure = environment.IsProduction(), + SameSite = SameSiteMode.Strict, + IsEssential = true, + Expires = DateTimeOffset.UtcNow.Add(jwtSettings.Value.RefreshTokenLifetime), + Path = "/api/auth/refresh" + }; + + Response.Cookies.Append("access_token", result.Value.AccessToken, accessTokenOptions); + Response.Cookies.Append("refresh_token", result.Value.RefreshToken, refreshTokenOptions); + + return HandleResult(Result.Ok()); } } \ No newline at end of file diff --git a/server/Presentation/Extensions/PresentationConfiguration.cs b/server/Presentation/Extensions/PresentationConfiguration.cs index cd0b2ae..1b7e8a0 100644 --- a/server/Presentation/Extensions/PresentationConfiguration.cs +++ b/server/Presentation/Extensions/PresentationConfiguration.cs @@ -93,6 +93,19 @@ private static void AddPutWikiAuthentication(this IServiceCollection services) ValidateLifetime = true, ClockSkew = TimeSpan.Zero }; + + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + if (context.Request.Cookies.TryGetValue("access_token", out var accessToken)) + { + context.Token = accessToken; + } + + return Task.CompletedTask; + } + }; }); services From 4cd4994cc41c025b8b1182527fd1ab33e393e2bc Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Sat, 24 Oct 2026 15:57:11 +0200 Subject: [PATCH 18/33] refactor: extract constants for cookies name --- server/Presentation/Controllers/AuthController.cs | 6 ++++-- server/Presentation/Extensions/AuthConstants.cs | 7 +++++++ .../Presentation/Extensions/PresentationConfiguration.cs | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 server/Presentation/Extensions/AuthConstants.cs diff --git a/server/Presentation/Controllers/AuthController.cs b/server/Presentation/Controllers/AuthController.cs index 0caa029..148bb93 100644 --- a/server/Presentation/Controllers/AuthController.cs +++ b/server/Presentation/Controllers/AuthController.cs @@ -14,6 +14,8 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using Presentation.Extensions; + namespace Presentation.Controllers; public class AuthController( @@ -68,8 +70,8 @@ CancellationToken ct Path = "/api/auth/refresh" }; - Response.Cookies.Append("access_token", result.Value.AccessToken, accessTokenOptions); - Response.Cookies.Append("refresh_token", result.Value.RefreshToken, refreshTokenOptions); + Response.Cookies.Append(AuthConstants.AccessTokenCookieName, result.Value.AccessToken, accessTokenOptions); + Response.Cookies.Append(AuthConstants.RefreshTokenCookieName, result.Value.RefreshToken, refreshTokenOptions); return HandleResult(Result.Ok()); } diff --git a/server/Presentation/Extensions/AuthConstants.cs b/server/Presentation/Extensions/AuthConstants.cs new file mode 100644 index 0000000..2abe23f --- /dev/null +++ b/server/Presentation/Extensions/AuthConstants.cs @@ -0,0 +1,7 @@ +namespace Presentation.Extensions; + +public static class AuthConstants +{ + public const string AccessTokenCookieName = "access_token"; + public const string RefreshTokenCookieName = "refresh_token"; +} \ No newline at end of file diff --git a/server/Presentation/Extensions/PresentationConfiguration.cs b/server/Presentation/Extensions/PresentationConfiguration.cs index 1b7e8a0..3c69038 100644 --- a/server/Presentation/Extensions/PresentationConfiguration.cs +++ b/server/Presentation/Extensions/PresentationConfiguration.cs @@ -98,7 +98,7 @@ private static void AddPutWikiAuthentication(this IServiceCollection services) { OnMessageReceived = context => { - if (context.Request.Cookies.TryGetValue("access_token", out var accessToken)) + if (context.Request.Cookies.TryGetValue(AuthConstants.AccessTokenCookieName, out var accessToken)) { context.Token = accessToken; } From f70b80ce74e6ba3ff74ffe2923864824b9c0af6e Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Sat, 24 Oct 2026 19:13:33 +0200 Subject: [PATCH 19/33] feat: add endpoint for refreshing session --- .../Application/Auth/IUserSessionService.cs | 10 + .../ProvisionUser => Auth}/JwtTokenPair.cs | 2 +- .../Extensions/ApplicationConfiguration.cs | 6 +- .../ProvisionUser/IUserSessionService.cs | 8 - .../Commands/ProvisionUser/LoginResponse.cs | 3 - .../ProvisionUserCommandHandler.cs | 4 +- .../RefreshSessionCommand.cs | 3 + .../RefreshSessionCommandHandler.cs | 28 ++ server/Application/packages.lock.json | 131 +++++- server/Domain/packages.lock.json | 25 +- .../Auth/JwtUserSessionService.cs | 22 +- server/Infrastructure/packages.lock.json | 359 ++++++++++++++- .../Controllers/AuthController.cs | 50 ++- server/Presentation/packages.lock.json | 415 +++++++++++++++++- server/PutWiki.UnitTests/packages.lock.json | 407 ++++++++++++++++- 15 files changed, 1441 insertions(+), 32 deletions(-) create mode 100644 server/Application/Auth/IUserSessionService.cs rename server/Application/{Features/Users/Commands/ProvisionUser => Auth}/JwtTokenPair.cs (51%) delete mode 100644 server/Application/Features/Users/Commands/ProvisionUser/IUserSessionService.cs delete mode 100644 server/Application/Features/Users/Commands/ProvisionUser/LoginResponse.cs create mode 100644 server/Application/Features/Users/Commands/RefreshUserSession/RefreshSessionCommand.cs create mode 100644 server/Application/Features/Users/Commands/RefreshUserSession/RefreshSessionCommandHandler.cs diff --git a/server/Application/Auth/IUserSessionService.cs b/server/Application/Auth/IUserSessionService.cs new file mode 100644 index 0000000..4a227e9 --- /dev/null +++ b/server/Application/Auth/IUserSessionService.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace Application.Auth; + +public interface IUserSessionService +{ + JwtTokenPair CreateSession(Guid userId); + Task RefreshSession(string refreshToken); +} \ No newline at end of file diff --git a/server/Application/Features/Users/Commands/ProvisionUser/JwtTokenPair.cs b/server/Application/Auth/JwtTokenPair.cs similarity index 51% rename from server/Application/Features/Users/Commands/ProvisionUser/JwtTokenPair.cs rename to server/Application/Auth/JwtTokenPair.cs index 9c0b15c..befd4b5 100644 --- a/server/Application/Features/Users/Commands/ProvisionUser/JwtTokenPair.cs +++ b/server/Application/Auth/JwtTokenPair.cs @@ -1,3 +1,3 @@ -namespace Application.Features.Users.Commands.ProvisionUser; +namespace Application.Auth; public record JwtTokenPair(string AccessToken, string RefreshToken); \ No newline at end of file diff --git a/server/Application/Extensions/ApplicationConfiguration.cs b/server/Application/Extensions/ApplicationConfiguration.cs index 6756be9..98a3c7e 100644 --- a/server/Application/Extensions/ApplicationConfiguration.cs +++ b/server/Application/Extensions/ApplicationConfiguration.cs @@ -1,8 +1,8 @@ -using Application.Features.AcademicTeachers.Queries; +using Application.Features.AcademicTeachers.Queries; using Application.Features.Users.Commands.ProvisionUser; using Application.Features.Users.Commands.UpdateNickname; +using Application.Features.Users.Commands.RefreshUserSession; using Application.Mappings; - using Microsoft.Extensions.DependencyInjection; namespace Application.Extensions; @@ -17,6 +17,8 @@ public static IServiceCollection AddApplication(this IServiceCollection services }); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/server/Application/Features/Users/Commands/ProvisionUser/IUserSessionService.cs b/server/Application/Features/Users/Commands/ProvisionUser/IUserSessionService.cs deleted file mode 100644 index e03dbd7..0000000 --- a/server/Application/Features/Users/Commands/ProvisionUser/IUserSessionService.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System; - -namespace Application.Features.Users.Commands.ProvisionUser; - -public interface IUserSessionService -{ - JwtTokenPair CreateSession(Guid userId); -} \ No newline at end of file diff --git a/server/Application/Features/Users/Commands/ProvisionUser/LoginResponse.cs b/server/Application/Features/Users/Commands/ProvisionUser/LoginResponse.cs deleted file mode 100644 index 659d49d..0000000 --- a/server/Application/Features/Users/Commands/ProvisionUser/LoginResponse.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Application.Features.Users.Commands.ProvisionUser; - -public sealed record LoginResponse(string AccessToken, string RefreshToken); \ No newline at end of file diff --git a/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs b/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs index c9e8df6..1d3288f 100644 --- a/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs +++ b/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs @@ -24,7 +24,7 @@ public partial class ProvisionUserCommandHandler( IUserSessionService userSessionService) { - public async Task> ExecuteAsync(ProvisionUserCommand cmd, + public async Task> ExecuteAsync(ProvisionUserCommand cmd, CancellationToken ct = default) { var usosResult = await usosOAuthService.HandleCallbackAndGetUserAsync(cmd.OauthToken, cmd.OauthVerifier, ct); @@ -58,7 +58,7 @@ public async Task> ExecuteAsync(ProvisionUserCommand cmd, await unitOfWork.SaveChangesAsync(ct); - return Result.Ok(new LoginResponse(tokens.AccessToken, tokens.RefreshToken)); + return Result.Ok(new JwtTokenPair(tokens.AccessToken, tokens.RefreshToken)); } [LoggerMessage(LogLevel.Warning, "Provisioning aborted: USOS authentication failed. Error: {error}")] diff --git a/server/Application/Features/Users/Commands/RefreshUserSession/RefreshSessionCommand.cs b/server/Application/Features/Users/Commands/RefreshUserSession/RefreshSessionCommand.cs new file mode 100644 index 0000000..73c7a16 --- /dev/null +++ b/server/Application/Features/Users/Commands/RefreshUserSession/RefreshSessionCommand.cs @@ -0,0 +1,3 @@ +namespace Application.Features.Users.Commands.RefreshUserSession; + +public record RefreshSessionCommand(string RefreshToken); \ No newline at end of file diff --git a/server/Application/Features/Users/Commands/RefreshUserSession/RefreshSessionCommandHandler.cs b/server/Application/Features/Users/Commands/RefreshUserSession/RefreshSessionCommandHandler.cs new file mode 100644 index 0000000..03c5354 --- /dev/null +++ b/server/Application/Features/Users/Commands/RefreshUserSession/RefreshSessionCommandHandler.cs @@ -0,0 +1,28 @@ +using System.Threading; +using System.Threading.Tasks; + +using Application.Abstractions; +using Application.Auth; +using Application.Errors; + +using FluentResults; + +namespace Application.Features.Users.Commands.RefreshUserSession; + +public class RefreshSessionCommandHandler( + IUserSessionService userSessionService, + IUnitOfWork unitOfWork) +{ + public async Task> ExecuteAsync(RefreshSessionCommand cmd, CancellationToken ct) + { + var newPair = await userSessionService.RefreshSession(cmd.RefreshToken); + if (newPair is null) + { + return Result.Fail(new UnauthorizedError("Cannot refresh user's session")); + } + + await unitOfWork.SaveChangesAsync(ct); + + return Result.Ok(newPair); + } +} \ No newline at end of file diff --git a/server/Application/packages.lock.json b/server/Application/packages.lock.json index 4a91a8c..22ef197 100644 --- a/server/Application/packages.lock.json +++ b/server/Application/packages.lock.json @@ -1,6 +1,133 @@ { - "version": 1, + "version": 2, "dependencies": { - "net10.0": {} + "net10.0": { + "AutoMapper": { + "type": "Direct", + "requested": "[16.3.0, )", + "resolved": "16.3.0", + "contentHash": "wFTqA37e1I4AOEhTTYlxrqDE8BQ4OB3dqhlv2KwYmHv+a4tn7R1JW2n2bzXb8eTBhWVBSl7iTZ0VxEt0a57D4w==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.IdentityModel.JsonWebTokens": "8.14.0" + } + }, + "FluentResults": { + "type": "Direct", + "requested": "[4.0.0, )", + "resolved": "4.0.0", + "contentHash": "wKfdLvDnDVhXlSreU7u/NFNHH6EfoOhAloEID4ePAyk+ZliowKZJvX+aCL4hxhFc9Jj74t62amS4LyOYTjXGCg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "2.1.1" + } + }, + "FluentValidation.DependencyInjectionExtensions": { + "type": "Direct", + "requested": "[12.2.0, )", + "resolved": "12.2.0", + "contentHash": "QpKXgiO9E6wXCh19X2Musn8MZM0xt7KQt/QrD6GDBn3MCUfy8a+rkhCzrYJNCfoxTsTRQ7bpKP8T3geTnJNNeQ==", + "dependencies": { + "FluentValidation": "12.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" + } + }, + "Moq": { + "type": "Direct", + "requested": "[4.21.0, )", + "resolved": "4.21.0", + "contentHash": "HUjrhhzEJ/AfbSBCnR8lPkpveVGpV/dNuOlnpfqBNTME93izly1CRV4N5zBySqz4m2yCRlHLPy/PnPzYokbwIg==", + "dependencies": { + "Castle.Core": "5.2.1" + } + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.35.0.4138, )", + "resolved": "10.35.0.4138", + "contentHash": "T5o/tuyPw9pHRkyWxW07TkSri4I4pLpqwH922JuhphDjHAM7BUHaGE67h/w+65evHsIar8mYB2Ey6OmOLj9QDw==" + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.2.1", + "contentHash": "wHARzQA695jwwKreOzNsq54KiGqKP38tv8hi8e2FXDEC/sA6BtrX90tVPDkOfVu13PbEzr00TCV8coikl+D1Iw==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "FluentValidation": { + "type": "Transitive", + "resolved": "12.2.0", + "contentHash": "DR3lFbdnKucaaj1/8zMNOACMz/64igzox1yVZSFVsLxYB5mxAB+wFP1WZ8fjwrZnbU1KA1DoMzHKYKxUfuZ4wQ==" + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "inRnbpCS0nwO/RuoZIAqxQUuyjaknOOnCEZB55KSMMjRhl0RQDttSmLSGsUJN3RQ3ocf5NDLFd2mOQViHqMK5w==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "4jOpiA4THdtpLyMdAb24dtj7+6GmvhOhxf5XHLYWmPKF8ApEnApal1UnJsKO4HxUWRXDA6C4WQVfYyqsRhpNpQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.14.0" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "lKIZiBiGd36k02TCdMHp1KlNWisyIvQxcYJvIkz7P4gSQ9zi8dgh6S5Grj8NNG7HWYIPfQymGyoZ6JB5d1Lo1g==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.IdentityModel.Logging": "8.14.0" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "domain": { + "type": "Project", + "dependencies": { + "FluentResults": "[4.0.0, )" + } + } + } } } \ No newline at end of file diff --git a/server/Domain/packages.lock.json b/server/Domain/packages.lock.json index 4a91a8c..bea516f 100644 --- a/server/Domain/packages.lock.json +++ b/server/Domain/packages.lock.json @@ -1,6 +1,27 @@ { - "version": 1, + "version": 2, "dependencies": { - "net10.0": {} + "net10.0": { + "FluentResults": { + "type": "Direct", + "requested": "[4.0.0, )", + "resolved": "4.0.0", + "contentHash": "wKfdLvDnDVhXlSreU7u/NFNHH6EfoOhAloEID4ePAyk+ZliowKZJvX+aCL4hxhFc9Jj74t62amS4LyOYTjXGCg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "2.1.1" + } + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.35.0.4138, )", + "resolved": "10.35.0.4138", + "contentHash": "T5o/tuyPw9pHRkyWxW07TkSri4I4pLpqwH922JuhphDjHAM7BUHaGE67h/w+65evHsIar8mYB2Ey6OmOLj9QDw==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "XRzK7ZF+O6FzdfWrlFTi1Rgj2080ZDsd46vzOjadHUB0Cz5kOvDG8vI7caa5YFrsHQpcfn0DxtjS4E46N4FZsA==" + } + } } } \ No newline at end of file diff --git a/server/Infrastructure/Auth/JwtUserSessionService.cs b/server/Infrastructure/Auth/JwtUserSessionService.cs index d478fe3..9c14a8b 100644 --- a/server/Infrastructure/Auth/JwtUserSessionService.cs +++ b/server/Infrastructure/Auth/JwtUserSessionService.cs @@ -3,11 +3,13 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Text; +using System.Threading.Tasks; -using Application.Features.Users.Commands.ProvisionUser; +using Application.Auth; using Infrastructure.Auth.Configuration; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; @@ -38,6 +40,24 @@ public JwtTokenPair CreateSession(Guid userId) return new JwtTokenPair(accessToken, refreshToken.Token); } + public async Task RefreshSession(string refreshToken) + { + var storedTokenObject = await dbContext.RefreshTokens + .FirstOrDefaultAsync(r => + r.Token == refreshToken && + !r.IsRevoked && + r.ExpiresAt > timeProvider.GetUtcNow()); + + if (storedTokenObject is null) + { + return null; + } + + storedTokenObject.IsRevoked = true; + + return CreateSession(storedTokenObject.UserId); + } + private string GenerateAccessToken(Guid userId) { var claims = new List diff --git a/server/Infrastructure/packages.lock.json b/server/Infrastructure/packages.lock.json index 4a91a8c..ab6160b 100644 --- a/server/Infrastructure/packages.lock.json +++ b/server/Infrastructure/packages.lock.json @@ -1,6 +1,361 @@ { - "version": 1, + "version": 2, "dependencies": { - "net10.0": {} + "net10.0": { + "EFCore.NamingConventions": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" + } + }, + "FluentResults": { + "type": "Direct", + "requested": "[4.0.0, )", + "resolved": "4.0.0", + "contentHash": "wKfdLvDnDVhXlSreU7u/NFNHH6EfoOhAloEID4ePAyk+ZliowKZJvX+aCL4hxhFc9Jj74t62amS4LyOYTjXGCg==" + }, + "Microsoft.AspNetCore.Authentication.JwtBearer": { + "type": "Direct", + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "cEPhQWsaUKymSdQ+NB2rF4GnWtjofAcRrzr2aGPzbjSFyeDtIlBzrbX1Qeh/OAHjvdwBe05SbsukoSbz27AD6g==", + "dependencies": { + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.19.2" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "I/IwEkUEUoa62UMJHh2+04oAs+gZz4QEunpZ3MGtAf12PZUcTduZLuGZEp3FAZ7wkmkSThZez41P8tgFj8QYKQ==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.12", + "Microsoft.Extensions.DependencyModel": "10.0.12", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.4" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.3" + } + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.35.0.4138, )", + "resolved": "10.35.0.4138", + "contentHash": "T5o/tuyPw9pHRkyWxW07TkSri4I4pLpqwH922JuhphDjHAM7BUHaGE67h/w+65evHsIar8mYB2Ey6OmOLj9QDw==" + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.2.1", + "contentHash": "wHARzQA695jwwKreOzNsq54KiGqKP38tv8hi8e2FXDEC/sA6BtrX90tVPDkOfVu13PbEzr00TCV8coikl+D1Iw==" + }, + "FluentValidation": { + "type": "Transitive", + "resolved": "12.2.0", + "contentHash": "DR3lFbdnKucaaj1/8zMNOACMz/64igzox1yVZSFVsLxYB5mxAB+wFP1WZ8fjwrZnbU1KA1DoMzHKYKxUfuZ4wQ==" + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==" + }, + "Microsoft.Build.Framework": { + "type": "Transitive", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "rDPQVTxh/zMTDF7wHlRqL/jjZbwjAP6315ydBxj51pZW7qkAwGwjoSiMutxYI91xmOE3ZXjAGHbYRqzyJV7Urw==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "r5YLDIxGOnkVJHrqXv/iD1FM1CgGrQOdriXuvuWvTPmKbnGANhEysq9XKmN6IHjf2a+9bAGEpnoRBsAQlvJU5w==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.19.2" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "sGxSsSrZXNmca6D+jHH2rVRyo2nNRd/g4H9CFbPmLLq0xgoH1U0orLWE5minfijw7+zq49tBs7txenbfAErRoQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "1XOcyY36cVymzE3qKdzKaUEZ4Pzt7ZpSa14JZoPPK1NLFUkQDs85TCqpV6XDo0YjFXj6nVK00AfOHppjghjhtw==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.19.2", + "System.IdentityModel.Tokens.Jwt": "8.19.2" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "GtPC1S02uH1gOO4fQ+zRysIicKmEXaYFP8PIkdJYXqMyruYhopre4ozVHp0XiDSA0+GJvOZH9prxCPvgBMg4Ww==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "10.0.2", + "Microsoft.IdentityModel.Logging": "8.19.2" + } + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "gqhDC/icByKEutygpr+OFgAmjwTVowyzFjWB8K0q1ww8uFj5a1BQNL+QvijUl9uhq4p8OdDwcAIrjVC+eC9FVA==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.19.2", + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "application": { + "type": "Project", + "dependencies": { + "AutoMapper": "[16.3.0, )", + "Domain": "[1.0.0, )", + "FluentResults": "[4.0.0, )", + "FluentValidation.DependencyInjectionExtensions": "[12.2.0, )", + "Moq": "[4.21.0, )" + } + }, + "domain": { + "type": "Project", + "dependencies": { + "FluentResults": "[4.0.0, )" + } + }, + "AutoMapper": { + "type": "CentralTransitive", + "requested": "[16.3.0, )", + "resolved": "16.3.0", + "contentHash": "wFTqA37e1I4AOEhTTYlxrqDE8BQ4OB3dqhlv2KwYmHv+a4tn7R1JW2n2bzXb8eTBhWVBSl7iTZ0VxEt0a57D4w==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.14.0" + } + }, + "FluentValidation.DependencyInjectionExtensions": { + "type": "CentralTransitive", + "requested": "[12.2.0, )", + "resolved": "12.2.0", + "contentHash": "QpKXgiO9E6wXCh19X2Musn8MZM0xt7KQt/QrD6GDBn3MCUfy8a+rkhCzrYJNCfoxTsTRQ7bpKP8T3geTnJNNeQ==", + "dependencies": { + "FluentValidation": "12.2.0" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.12", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.12" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.12" + } + }, + "Moq": { + "type": "CentralTransitive", + "requested": "[4.21.0, )", + "resolved": "4.21.0", + "contentHash": "HUjrhhzEJ/AfbSBCnR8lPkpveVGpV/dNuOlnpfqBNTME93izly1CRV4N5zBySqz4m2yCRlHLPy/PnPzYokbwIg==", + "dependencies": { + "Castle.Core": "5.2.1" + } + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==" + } + } } } \ No newline at end of file diff --git a/server/Presentation/Controllers/AuthController.cs b/server/Presentation/Controllers/AuthController.cs index 148bb93..8779f28 100644 --- a/server/Presentation/Controllers/AuthController.cs +++ b/server/Presentation/Controllers/AuthController.cs @@ -3,7 +3,9 @@ using System.Threading.Tasks; using Application.Auth; +using Application.Errors; using Application.Features.Users.Commands.ProvisionUser; +using Application.Features.Users.Commands.RefreshUserSession; using FluentResults; @@ -16,11 +18,14 @@ using Presentation.Extensions; +using Scalar.AspNetCore; + namespace Presentation.Controllers; public class AuthController( IUsosOAuthService usosOAuthService, ProvisionUserCommandHandler provisionUserCommandHandler, + RefreshSessionCommandHandler refreshSessionCommandHandler, IOptions jwtSettings, IHostEnvironment environment) : BaseApiController { @@ -38,7 +43,8 @@ public async Task Login(CancellationToken ct) } [HttpGet("callback")] - [ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)] + [ExcludeFromApiReference] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status502BadGateway)] @@ -51,6 +57,42 @@ CancellationToken ct var command = new ProvisionUserCommand(oauthToken, oauthVerifier); var result = await provisionUserCommandHandler.ExecuteAsync(command, ct); + if (result.IsFailed) + { + return HandleResult(result); + } + + AppendTokenCookies(result.Value); + + return HandleResult(Result.Ok()); + } + + [HttpGet("refresh")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + public async Task Refresh(CancellationToken ct) + { + var success = Request.Cookies.TryGetValue(AuthConstants.RefreshTokenCookieName, out string? refreshToken); + if (!success || refreshToken is null) + { + return HandleResult(new UnauthorizedError("Refresh token not found")); + } + + var command = new RefreshSessionCommand(refreshToken); + var result = await refreshSessionCommandHandler.ExecuteAsync(command, ct); + + if (result.IsFailed) + { + return HandleResult(result); + } + + AppendTokenCookies(result.Value); + + return HandleResult(Result.Ok()); + } + + private void AppendTokenCookies(JwtTokenPair tokenPair) + { var accessTokenOptions = new CookieOptions { HttpOnly = true, @@ -70,9 +112,7 @@ CancellationToken ct Path = "/api/auth/refresh" }; - Response.Cookies.Append(AuthConstants.AccessTokenCookieName, result.Value.AccessToken, accessTokenOptions); - Response.Cookies.Append(AuthConstants.RefreshTokenCookieName, result.Value.RefreshToken, refreshTokenOptions); - - return HandleResult(Result.Ok()); + Response.Cookies.Append(AuthConstants.AccessTokenCookieName, tokenPair.AccessToken, accessTokenOptions); + Response.Cookies.Append(AuthConstants.RefreshTokenCookieName, tokenPair.RefreshToken, refreshTokenOptions); } } \ No newline at end of file diff --git a/server/Presentation/packages.lock.json b/server/Presentation/packages.lock.json index 4a91a8c..813920f 100644 --- a/server/Presentation/packages.lock.json +++ b/server/Presentation/packages.lock.json @@ -1,6 +1,417 @@ { - "version": 1, + "version": 2, "dependencies": { - "net10.0": {} + "net10.0": { + "DotNetEnv": { + "type": "Direct", + "requested": "[3.2.0, )", + "resolved": "3.2.0", + "contentHash": "2q+PD+aJcRW9QuRcIv4F8WbvEDxI6cwxzZJec2i45cBY4cAKCAoW4ZPkApJ7H82wr6Yfmxx/0kb1nLnRmZrGZA==", + "dependencies": { + "Superpower": "3.0.0" + } + }, + "FluentResults": { + "type": "Direct", + "requested": "[4.0.0, )", + "resolved": "4.0.0", + "contentHash": "wKfdLvDnDVhXlSreU7u/NFNHH6EfoOhAloEID4ePAyk+ZliowKZJvX+aCL4hxhFc9Jj74t62amS4LyOYTjXGCg==" + }, + "Microsoft.AspNetCore.Authentication.JwtBearer": { + "type": "Direct", + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "cEPhQWsaUKymSdQ+NB2rF4GnWtjofAcRrzr2aGPzbjSFyeDtIlBzrbX1Qeh/OAHjvdwBe05SbsukoSbz27AD6g==", + "dependencies": { + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.19.2" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "Direct", + "requested": "[10.0.14, )", + "resolved": "10.0.14", + "contentHash": "3M6yO4nv8fmvM/1X7DqQcMxw/ptNLBcUzZt19SBEyfZF32gqZuwoPNnYd1xlDnFvQOGOkZDncf7p9E24blcTkQ==", + "dependencies": { + "Microsoft.OpenApi": "[2.12.2, 3.0.0)" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "I/IwEkUEUoa62UMJHh2+04oAs+gZz4QEunpZ3MGtAf12PZUcTduZLuGZEp3FAZ7wkmkSThZez41P8tgFj8QYKQ==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.12", + "Microsoft.Extensions.DependencyModel": "10.0.12", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.4" + } + }, + "Scalar.AspNetCore": { + "type": "Direct", + "requested": "[2.19.0, )", + "resolved": "2.19.0", + "contentHash": "nTXL9wsg6UhQ27KPUhuMr3/BE02H3zjuii2I5x7vbx7myPHkJzvzGDj7hfd1lKZBmJgUcKI+W7wcIzDdV/N/Dg==" + }, + "Scalar.AspNetCore.Microsoft": { + "type": "Direct", + "requested": "[2.19.0, )", + "resolved": "2.19.0", + "contentHash": "k4i3WbQV3XOgdKg9ez4F3fXB7alq68r7Rh6+WFOjeGREKuMPAuPa7djZVz/NKTjl2Lde8lnsCkArd1dAE+xB7A==", + "dependencies": { + "Microsoft.AspNetCore.OpenApi": "10.0.0", + "Microsoft.OpenApi": "2.7.5", + "Scalar.AspNetCore": "2.19.0" + } + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.35.0.4138, )", + "resolved": "10.35.0.4138", + "contentHash": "T5o/tuyPw9pHRkyWxW07TkSri4I4pLpqwH922JuhphDjHAM7BUHaGE67h/w+65evHsIar8mYB2Ey6OmOLj9QDw==" + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.2.1", + "contentHash": "wHARzQA695jwwKreOzNsq54KiGqKP38tv8hi8e2FXDEC/sA6BtrX90tVPDkOfVu13PbEzr00TCV8coikl+D1Iw==" + }, + "FluentValidation": { + "type": "Transitive", + "resolved": "12.2.0", + "contentHash": "DR3lFbdnKucaaj1/8zMNOACMz/64igzox1yVZSFVsLxYB5mxAB+wFP1WZ8fjwrZnbU1KA1DoMzHKYKxUfuZ4wQ==" + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==" + }, + "Microsoft.Build.Framework": { + "type": "Transitive", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "rDPQVTxh/zMTDF7wHlRqL/jjZbwjAP6315ydBxj51pZW7qkAwGwjoSiMutxYI91xmOE3ZXjAGHbYRqzyJV7Urw==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "r5YLDIxGOnkVJHrqXv/iD1FM1CgGrQOdriXuvuWvTPmKbnGANhEysq9XKmN6IHjf2a+9bAGEpnoRBsAQlvJU5w==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.19.2" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "sGxSsSrZXNmca6D+jHH2rVRyo2nNRd/g4H9CFbPmLLq0xgoH1U0orLWE5minfijw7+zq49tBs7txenbfAErRoQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "1XOcyY36cVymzE3qKdzKaUEZ4Pzt7ZpSa14JZoPPK1NLFUkQDs85TCqpV6XDo0YjFXj6nVK00AfOHppjghjhtw==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.19.2", + "System.IdentityModel.Tokens.Jwt": "8.19.2" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "GtPC1S02uH1gOO4fQ+zRysIicKmEXaYFP8PIkdJYXqMyruYhopre4ozVHp0XiDSA0+GJvOZH9prxCPvgBMg4Ww==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "10.0.2", + "Microsoft.IdentityModel.Logging": "8.19.2" + } + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.12.2", + "contentHash": "ZkT7qiJe0Kj0+DTZOZo+xrDy1kNmdsWFQVPIQLNx9ZJ0T9cB2LG7r+Bi60so8/+yg1nkS7QCPadrzsTG4VPKrA==" + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Superpower": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "bjKbAYWePooCAvPxQq+3KnmcLfRggMyRoXmtBMlmCG71bdwBHrO6rmRJ2DRIodg5aztNcxeZXBUVWT+H+MZUhw==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "gqhDC/icByKEutygpr+OFgAmjwTVowyzFjWB8K0q1ww8uFj5a1BQNL+QvijUl9uhq4p8OdDwcAIrjVC+eC9FVA==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.19.2", + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "application": { + "type": "Project", + "dependencies": { + "AutoMapper": "[16.3.0, )", + "Domain": "[1.0.0, )", + "FluentResults": "[4.0.0, )", + "FluentValidation.DependencyInjectionExtensions": "[12.2.0, )", + "Moq": "[4.21.0, )" + } + }, + "domain": { + "type": "Project", + "dependencies": { + "FluentResults": "[4.0.0, )" + } + }, + "infrastructure": { + "type": "Project", + "dependencies": { + "Application": "[1.0.0, )", + "Domain": "[1.0.0, )", + "EFCore.NamingConventions": "[10.0.1, )", + "FluentResults": "[4.0.0, )", + "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.13, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" + } + }, + "AutoMapper": { + "type": "CentralTransitive", + "requested": "[16.3.0, )", + "resolved": "16.3.0", + "contentHash": "wFTqA37e1I4AOEhTTYlxrqDE8BQ4OB3dqhlv2KwYmHv+a4tn7R1JW2n2bzXb8eTBhWVBSl7iTZ0VxEt0a57D4w==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.14.0" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" + } + }, + "FluentValidation.DependencyInjectionExtensions": { + "type": "CentralTransitive", + "requested": "[12.2.0, )", + "resolved": "12.2.0", + "contentHash": "QpKXgiO9E6wXCh19X2Musn8MZM0xt7KQt/QrD6GDBn3MCUfy8a+rkhCzrYJNCfoxTsTRQ7bpKP8T3geTnJNNeQ==", + "dependencies": { + "FluentValidation": "12.2.0" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.12", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.12" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.12" + } + }, + "Moq": { + "type": "CentralTransitive", + "requested": "[4.21.0, )", + "resolved": "4.21.0", + "contentHash": "HUjrhhzEJ/AfbSBCnR8lPkpveVGpV/dNuOlnpfqBNTME93izly1CRV4N5zBySqz4m2yCRlHLPy/PnPzYokbwIg==", + "dependencies": { + "Castle.Core": "5.2.1" + } + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==" + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.3" + } + } + } } } \ No newline at end of file diff --git a/server/PutWiki.UnitTests/packages.lock.json b/server/PutWiki.UnitTests/packages.lock.json index 4a91a8c..c22cb60 100644 --- a/server/PutWiki.UnitTests/packages.lock.json +++ b/server/PutWiki.UnitTests/packages.lock.json @@ -1,6 +1,409 @@ { - "version": 1, + "version": 2, "dependencies": { - "net10.0": {} + "net10.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[10.1.0, )", + "resolved": "10.1.0", + "contentHash": "SlYCIHnBYxINSUpR75SiM5JEmnx11pqT+Ac/C/fUqTTA0dLPl9eYzmFXKKuxmQhL3ZdTQl4PBD3YdAxAauaKLQ==" + }, + "FluentAssertions": { + "type": "Direct", + "requested": "[8.11.0, )", + "resolved": "8.11.0", + "contentHash": "dZqOxys9uY/0vO5BAwFtDECkFNSr9vWGwHa1fwZyuM1xAMbI4wiPTUBR2nH5e6R2/bQkXegxFY1B/oU1yqNNOw==" + }, + "FluentResults": { + "type": "Direct", + "requested": "[4.0.0, )", + "resolved": "4.0.0", + "contentHash": "wKfdLvDnDVhXlSreU7u/NFNHH6EfoOhAloEID4ePAyk+ZliowKZJvX+aCL4hxhFc9Jj74t62amS4LyOYTjXGCg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "2.1.1" + } + }, + "Microsoft.Extensions.TimeProvider.Testing": { + "type": "Direct", + "requested": "[10.11.0, )", + "resolved": "10.11.0", + "contentHash": "DTivKZ2Du0dJideTTa6vPj/eXKNRu+RxtjBX7NOOUXHBtHx1ehbE+lM+j3/5M3wU6BqzcZqEEyR4+FnPKs2rTA==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.11.0, )", + "resolved": "18.11.0", + "contentHash": "xf/bR0LNkhK0SnadDRlZIQmC4sac5Wk0e1WAdiQjRN3MSo6gQ9M2c4c1XRNnVGjfOvckh704W5CJ19AL0/Xsdg==", + "dependencies": { + "Microsoft.CodeCoverage": "18.11.0", + "Microsoft.TestPlatform.TestHost": "18.11.0" + } + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.35.0.4138, )", + "resolved": "10.35.0.4138", + "contentHash": "T5o/tuyPw9pHRkyWxW07TkSri4I4pLpqwH922JuhphDjHAM7BUHaGE67h/w+65evHsIar8mYB2Ey6OmOLj9QDw==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "8CoAaZkniVekR1ycJyxn+hOz+6ZQCDPcoIsIs6ujd4WyFcBMlxGNU15Qb8Uxd4iO4UmOuETb0M6vzBbwX/mfLA==" + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.2.1", + "contentHash": "wHARzQA695jwwKreOzNsq54KiGqKP38tv8hi8e2FXDEC/sA6BtrX90tVPDkOfVu13PbEzr00TCV8coikl+D1Iw==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "FluentValidation": { + "type": "Transitive", + "resolved": "12.2.0", + "contentHash": "DR3lFbdnKucaaj1/8zMNOACMz/64igzox1yVZSFVsLxYB5mxAB+wFP1WZ8fjwrZnbU1KA1DoMzHKYKxUfuZ4wQ==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.11.0", + "contentHash": "MhN9wpvL1Es4hfIYslrsVgIsUdDFxuOBTbAl9mGIMvFo8PP5CLf4DrGdNrfwWSyn0lqi6sssm+I/qkkLL3MU+g==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "sI3A6MUAyZwccLnwq0cXuRmC3WBtXieimBWafXSfhRJ4jnM2uGVXrqImDDVyW27u4vVaTOGlJxNJd8d1FGvCNQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "fhC3DHcBcFF4bXvHIxFtXJYNut7q7ZItqNFcgWfrIgmpVImlcEKNHhE7ztpNSPJYMHYYStS5QGND2I5mC26yeA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.12", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "r5YLDIxGOnkVJHrqXv/iD1FM1CgGrQOdriXuvuWvTPmKbnGANhEysq9XKmN6IHjf2a+9bAGEpnoRBsAQlvJU5w==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.19.2" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "sGxSsSrZXNmca6D+jHH2rVRyo2nNRd/g4H9CFbPmLLq0xgoH1U0orLWE5minfijw7+zq49tBs7txenbfAErRoQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "1XOcyY36cVymzE3qKdzKaUEZ4Pzt7ZpSa14JZoPPK1NLFUkQDs85TCqpV6XDo0YjFXj6nVK00AfOHppjghjhtw==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.19.2", + "System.IdentityModel.Tokens.Jwt": "8.19.2" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "GtPC1S02uH1gOO4fQ+zRysIicKmEXaYFP8PIkdJYXqMyruYhopre4ozVHp0XiDSA0+GJvOZH9prxCPvgBMg4Ww==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "10.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.IdentityModel.Logging": "8.19.2" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.11.0", + "contentHash": "BXBHYnwSOljKute8UYCMXOLAdB7awxEMppPSpqOF1ZxLD/W/OH+vTBgFaXA7q0aditJTVXSMVenel+wOkEIyNg==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.11.0", + "contentHash": "AKjwKpyRBwoQFQmpcchtoYlfJEBlaY/40uxBRi+qXP4NR7xT8t+XTJtOyEyumfvfJcH5pASs75LwNdXYRr4XNA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.11.0" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "gqhDC/icByKEutygpr+OFgAmjwTVowyzFjWB8K0q1ww8uFj5a1BQNL+QvijUl9uhq4p8OdDwcAIrjVC+eC9FVA==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.19.2", + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "application": { + "type": "Project", + "dependencies": { + "AutoMapper": "[16.3.0, )", + "Domain": "[1.0.0, )", + "FluentResults": "[4.0.0, )", + "FluentValidation.DependencyInjectionExtensions": "[12.2.0, )", + "Moq": "[4.21.0, )" + } + }, + "domain": { + "type": "Project", + "dependencies": { + "FluentResults": "[4.0.0, )" + } + }, + "infrastructure": { + "type": "Project", + "dependencies": { + "Application": "[1.0.0, )", + "Domain": "[1.0.0, )", + "EFCore.NamingConventions": "[10.0.1, )", + "FluentResults": "[4.0.0, )", + "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.13, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" + } + }, + "AutoMapper": { + "type": "CentralTransitive", + "requested": "[16.3.0, )", + "resolved": "16.3.0", + "contentHash": "wFTqA37e1I4AOEhTTYlxrqDE8BQ4OB3dqhlv2KwYmHv+a4tn7R1JW2n2bzXb8eTBhWVBSl7iTZ0VxEt0a57D4w==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.IdentityModel.JsonWebTokens": "8.14.0" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "FluentValidation.DependencyInjectionExtensions": { + "type": "CentralTransitive", + "requested": "[12.2.0, )", + "resolved": "12.2.0", + "contentHash": "QpKXgiO9E6wXCh19X2Musn8MZM0xt7KQt/QrD6GDBn3MCUfy8a+rkhCzrYJNCfoxTsTRQ7bpKP8T3geTnJNNeQ==", + "dependencies": { + "FluentValidation": "12.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" + } + }, + "Microsoft.AspNetCore.Authentication.JwtBearer": { + "type": "CentralTransitive", + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "cEPhQWsaUKymSdQ+NB2rF4GnWtjofAcRrzr2aGPzbjSFyeDtIlBzrbX1Qeh/OAHjvdwBe05SbsukoSbz27AD6g==", + "dependencies": { + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.19.2" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.12", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.12", + "Microsoft.Extensions.Caching.Memory": "10.0.12", + "Microsoft.Extensions.Logging": "10.0.12" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.12", + "Microsoft.Extensions.Caching.Memory": "10.0.12", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Logging": "10.0.12" + } + }, + "Moq": { + "type": "CentralTransitive", + "requested": "[4.21.0, )", + "resolved": "4.21.0", + "contentHash": "HUjrhhzEJ/AfbSBCnR8lPkpveVGpV/dNuOlnpfqBNTME93izly1CRV4N5zBySqz4m2yCRlHLPy/PnPzYokbwIg==", + "dependencies": { + "Castle.Core": "5.2.1" + } + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.3" + } + } + } } } \ No newline at end of file From 90ffb003090db3b088b5ea86c735cb108cd5debc Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Sun, 25 Oct 2026 00:48:36 +0200 Subject: [PATCH 20/33] refactor(JwtUserSessionServiceTest): replace dbcontext mocking with in-memory db --- server/Directory.Packages.props | 7 +- server/Infrastructure/AppDbContext.cs | 6 +- server/Infrastructure/packages.lock.json | 40 ++-- server/Presentation/packages.lock.json | 40 ++-- .../Auth/JwtUserSessionServiceTest.cs | 97 ++++++---- .../PutWiki.UnitTests.csproj | 1 + server/PutWiki.UnitTests/packages.lock.json | 171 ++++++++++++------ 7 files changed, 226 insertions(+), 136 deletions(-) diff --git a/server/Directory.Packages.props b/server/Directory.Packages.props index 8ec85f6..65b78f8 100644 --- a/server/Directory.Packages.props +++ b/server/Directory.Packages.props @@ -15,12 +15,13 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/server/Infrastructure/AppDbContext.cs b/server/Infrastructure/AppDbContext.cs index 0790334..10e57b6 100644 --- a/server/Infrastructure/AppDbContext.cs +++ b/server/Infrastructure/AppDbContext.cs @@ -12,9 +12,9 @@ namespace Infrastructure; public class AppDbContext(DbContextOptions options) : DbContext(options), IUnitOfWork { - public required DbSet Users { get; set; } - public required DbSet AcademicTeachers { get; set; } - public virtual required DbSet RefreshTokens { get; set; } + public DbSet Users => Set(); + public DbSet AcademicTeachers => Set(); + public virtual DbSet RefreshTokens => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/server/Infrastructure/packages.lock.json b/server/Infrastructure/packages.lock.json index ab6160b..8b5bd27 100644 --- a/server/Infrastructure/packages.lock.json +++ b/server/Infrastructure/packages.lock.json @@ -29,17 +29,17 @@ }, "Microsoft.EntityFrameworkCore.Design": { "type": "Direct", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "I/IwEkUEUoa62UMJHh2+04oAs+gZz4QEunpZ3MGtAf12PZUcTduZLuGZEp3FAZ7wkmkSThZez41P8tgFj8QYKQ==", + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "cxYpgXy9/5+ddwlKY7K196zV0W2W+N7JkPyAK0LVgf4XSJT4exfUURjsgcLHA4KOwWYQtyKZfIHpmTqDrkkCpg==", "dependencies": { "Humanizer.Core": "2.14.1", "Microsoft.Build.Framework": "18.0.2", "Microsoft.CodeAnalysis.CSharp": "5.0.0", "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", - "Microsoft.EntityFrameworkCore.Relational": "10.0.12", - "Microsoft.Extensions.DependencyModel": "10.0.12", + "Microsoft.EntityFrameworkCore.Relational": "10.0.13", + "Microsoft.Extensions.DependencyModel": "10.0.13", "Mono.TextTemplating": "3.0.0", "Newtonsoft.Json": "13.0.4" } @@ -148,18 +148,18 @@ }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg==" + "resolved": "10.0.13", + "contentHash": "ARw+tZ3eiTosnK5nJPUGEzeUQcjyqCqs3qKA3lPtIRH5yJo3qIgxm7AvkZVIT9iWkS2083UsWe3Rj1MysW3p5A==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ==" + "resolved": "10.0.13", + "contentHash": "zBx6VeEz/hkhlm0BJ2V+9xebUuDDIszcKvurJceQHuZPEXuXubrL6Lneo9VZiliRyJbQlB2oNKLkJXd3gHteQA==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "rDPQVTxh/zMTDF7wHlRqL/jjZbwjAP6315ydBxj51pZW7qkAwGwjoSiMutxYI91xmOE3ZXjAGHbYRqzyJV7Urw==" + "resolved": "10.0.13", + "contentHash": "6TyllZsToy3S/Ziu67GddljRTttuVJ8bbrbVUrsdHqH5678Oax5MpaEubxLoPoWLvgUBYo4KfcAig0mkBtktXQ==" }, "Microsoft.IdentityModel.Abstractions": { "type": "Transitive", @@ -324,21 +324,21 @@ }, "Microsoft.EntityFrameworkCore": { "type": "CentralTransitive", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==", + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "u/z4nTQUJ5nJEXAQekgQEQZkh0delp4gL+ESHn7Z6k037c8HC5mSNu1/YdBgxGm7DtLjhOxktNl+aQMzw6wN7A==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.12", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.12" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.13", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.13" } }, "Microsoft.EntityFrameworkCore.Relational": { "type": "CentralTransitive", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==", + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "QU/sbTtDR5wVAJRVecC1GwM7mwH8bvKOZNm+lulzMr5rhjdb8DDEFIqqdhF08JIo51DPv5uQdZ1nXoZsD5pTag==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.12" + "Microsoft.EntityFrameworkCore": "10.0.13" } }, "Moq": { diff --git a/server/Presentation/packages.lock.json b/server/Presentation/packages.lock.json index 813920f..f8996a8 100644 --- a/server/Presentation/packages.lock.json +++ b/server/Presentation/packages.lock.json @@ -37,17 +37,17 @@ }, "Microsoft.EntityFrameworkCore.Design": { "type": "Direct", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "I/IwEkUEUoa62UMJHh2+04oAs+gZz4QEunpZ3MGtAf12PZUcTduZLuGZEp3FAZ7wkmkSThZez41P8tgFj8QYKQ==", + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "cxYpgXy9/5+ddwlKY7K196zV0W2W+N7JkPyAK0LVgf4XSJT4exfUURjsgcLHA4KOwWYQtyKZfIHpmTqDrkkCpg==", "dependencies": { "Humanizer.Core": "2.14.1", "Microsoft.Build.Framework": "18.0.2", "Microsoft.CodeAnalysis.CSharp": "5.0.0", "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", - "Microsoft.EntityFrameworkCore.Relational": "10.0.12", - "Microsoft.Extensions.DependencyModel": "10.0.12", + "Microsoft.EntityFrameworkCore.Relational": "10.0.13", + "Microsoft.Extensions.DependencyModel": "10.0.13", "Mono.TextTemplating": "3.0.0", "Newtonsoft.Json": "13.0.4" } @@ -162,18 +162,18 @@ }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg==" + "resolved": "10.0.13", + "contentHash": "ARw+tZ3eiTosnK5nJPUGEzeUQcjyqCqs3qKA3lPtIRH5yJo3qIgxm7AvkZVIT9iWkS2083UsWe3Rj1MysW3p5A==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ==" + "resolved": "10.0.13", + "contentHash": "zBx6VeEz/hkhlm0BJ2V+9xebUuDDIszcKvurJceQHuZPEXuXubrL6Lneo9VZiliRyJbQlB2oNKLkJXd3gHteQA==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "rDPQVTxh/zMTDF7wHlRqL/jjZbwjAP6315ydBxj51pZW7qkAwGwjoSiMutxYI91xmOE3ZXjAGHbYRqzyJV7Urw==" + "resolved": "10.0.13", + "contentHash": "6TyllZsToy3S/Ziu67GddljRTttuVJ8bbrbVUrsdHqH5678Oax5MpaEubxLoPoWLvgUBYo4KfcAig0mkBtktXQ==" }, "Microsoft.IdentityModel.Abstractions": { "type": "Transitive", @@ -369,21 +369,21 @@ }, "Microsoft.EntityFrameworkCore": { "type": "CentralTransitive", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==", + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "u/z4nTQUJ5nJEXAQekgQEQZkh0delp4gL+ESHn7Z6k037c8HC5mSNu1/YdBgxGm7DtLjhOxktNl+aQMzw6wN7A==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.12", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.12" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.13", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.13" } }, "Microsoft.EntityFrameworkCore.Relational": { "type": "CentralTransitive", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==", + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "QU/sbTtDR5wVAJRVecC1GwM7mwH8bvKOZNm+lulzMr5rhjdb8DDEFIqqdhF08JIo51DPv5uQdZ1nXoZsD5pTag==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.12" + "Microsoft.EntityFrameworkCore": "10.0.13" } }, "Moq": { diff --git a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs index a387952..596db4d 100644 --- a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs +++ b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs @@ -1,6 +1,7 @@ using System; -using System.Collections.Generic; +using System.Data.Common; using System.Linq; +using System.Threading.Tasks; using FluentAssertions; @@ -8,42 +9,45 @@ using Infrastructure.Auth; using Infrastructure.Auth.Configuration; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using Microsoft.IdentityModel.JsonWebTokens; -using Moq; - namespace PutWiki.UnitTests.Infrastructure.Auth; -public class JwtUserSessionServiceTest +public sealed class JwtUserSessionServiceTest : IDisposable { private const string ValidSecretKey = "secret-key-that-is-32-bytes-long"; private const string ValidIssuer = "putwiki-api"; private const string ValidAudience = "putwiki-frontend"; - private readonly Mock> _dbSetMock; - private readonly Mock _dbContextMock; private readonly FakeTimeProvider _fakeTimeProvider; - private readonly List _refreshTokenStore; + + private readonly DbConnection _connection; + private readonly DbContextOptions _contextOptions; public JwtUserSessionServiceTest() { - _dbSetMock = new Mock>(); _fakeTimeProvider = new FakeTimeProvider(); - _refreshTokenStore = new List(); - var dbContextOptions = new DbContextOptionsBuilder().Options; - _dbContextMock = new Mock(dbContextOptions); + _connection = new SqliteConnection("Filename=:memory:"); + _connection.Open(); - _dbSetMock - .Setup(x => x.Add(It.IsAny())) - .Callback(x => _refreshTokenStore.Add(x)); + _contextOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; - _dbContextMock - .Setup(x => x.RefreshTokens) - .Returns(_dbSetMock.Object); + using var context = CreateContext(); + context.Database.EnsureCreated(); + } + + AppDbContext CreateContext() => new AppDbContext(_contextOptions); + + public void Dispose() + { + _connection.Dispose(); } private static IOptions CreateOptions( @@ -66,15 +70,17 @@ private static IOptions CreateOptions( } [Fact] - public void CreateSession_GivenValidUserId_ReturnsValidJwtAndRefreshTokenPair() + public async Task CreateSession_GivenValidUserId_ReturnsValidJwtAndRefreshTokenPair() { // Arrange var userId = Guid.NewGuid(); var options = CreateOptions(); - var sut = new JwtUserSessionService(options, _dbContextMock.Object, _fakeTimeProvider); + using var context = CreateContext(); + var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); // Act var result = sut.CreateSession(userId); + await context.SaveChangesAsync(); // Assert result.Should().NotBeNull(); @@ -87,39 +93,45 @@ public void CreateSession_GivenValidUserId_ReturnsValidJwtAndRefreshTokenPair() } [Fact] - public void CreateSession_GivenValidUserId_PersistsRefreshToken() + public async Task CreateSession_GivenValidUserId_PersistsRefreshToken() { // Arrange var userId = Guid.NewGuid(); var options = CreateOptions(); - var sut = new JwtUserSessionService(options, _dbContextMock.Object, _fakeTimeProvider); + using var context = CreateContext(); + + var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); // Act var result = sut.CreateSession(userId); + await context.SaveChangesAsync(); // Assert - _dbSetMock.Verify(x => x.Add(It.Is(rt => + var refreshToken = await context.RefreshTokens.SingleAsync(rt => rt.UserId == userId && rt.Token == result.RefreshToken && - rt.Id != Guid.Empty - )), Times.Once); + rt.Id != Guid.Empty); + + refreshToken.Should().NotBeNull(); } [Fact] - public void CreateSession_GivenConfiguredLifetimes_SetsCorrectAccessTokenExpirationTime() + public async Task CreateSession_GivenConfiguredLifetimes_SetsCorrectAccessTokenExpirationTime() { // Arrange var userId = Guid.NewGuid(); var accessTokenLifetime = TimeSpan.FromMinutes(45); var options = CreateOptions(accessTokenLifetime: accessTokenLifetime); + using var context = CreateContext(); var fixedTime = new DateTimeOffset(2026, 10, 22, 23, 0, 0, TimeSpan.Zero); _fakeTimeProvider.SetUtcNow(fixedTime); - var sut = new JwtUserSessionService(options, _dbContextMock.Object, _fakeTimeProvider); + var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); // Act var result = sut.CreateSession(userId); + await context.SaveChangesAsync(); // Assert var handler = new JsonWebTokenHandler(); @@ -130,74 +142,82 @@ public void CreateSession_GivenConfiguredLifetimes_SetsCorrectAccessTokenExpirat } [Fact] - public void CreateSession_GivenConfiguredLifetimes_SetsCorrectRefreshTokenExpirationAndCreationDates() + public async Task CreateSession_GivenConfiguredLifetimes_SetsCorrectRefreshTokenExpirationAndCreationDates() { // Arrange var userId = Guid.NewGuid(); var refreshTokenLifetime = TimeSpan.FromDays(14); var options = CreateOptions(refreshTokenLifetime: refreshTokenLifetime); + using var context = CreateContext(); var fixedTime = new DateTimeOffset(2026, 10, 22, 23, 0, 0, TimeSpan.Zero); _fakeTimeProvider.SetUtcNow(fixedTime); - var sut = new JwtUserSessionService(options, _dbContextMock.Object, _fakeTimeProvider); + var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); // Act sut.CreateSession(userId); + await context.SaveChangesAsync(); // Assert - var savedRefreshToken = _refreshTokenStore.Single(rt => rt.UserId == userId); + var savedRefreshToken = await context.RefreshTokens.SingleAsync(rt => rt.UserId == userId); savedRefreshToken.CreatedAt.Should().Be(fixedTime); savedRefreshToken.ExpiresAt.Should().Be(fixedTime.Add(refreshTokenLifetime)); } [Fact] - public void CreateSession_WhenCalledMultipleTimesForSameUser_GeneratesUniqueRefreshTokens() + public async Task CreateSession_WhenCalledMultipleTimesForSameUser_GeneratesUniqueRefreshTokens() { // Arrange var userId = Guid.NewGuid(); var options = CreateOptions(); - var sut = new JwtUserSessionService(options, _dbContextMock.Object, _fakeTimeProvider); + using var context = CreateContext(); + var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); // Act var session1 = sut.CreateSession(userId); var session2 = sut.CreateSession(userId); + await context.SaveChangesAsync(); // Assert session1.RefreshToken.Should().NotBe(session2.RefreshToken); } [Fact] - public void CreateSession_WhenCalledMultipleTimesForSameUser_StoresAllRefreshTokensNotOverwritesThem() + public async Task CreateSession_WhenCalledMultipleTimesForSameUser_StoresAllRefreshTokensNotOverwritesThem() { // Arrange var userId = Guid.NewGuid(); var options = CreateOptions(); - var sut = new JwtUserSessionService(options, _dbContextMock.Object, _fakeTimeProvider); + using var context = CreateContext(); + var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); // Act sut.CreateSession(userId); sut.CreateSession(userId); + await context.SaveChangesAsync(); // Assert - _dbSetMock.Verify(x => x.Add(It.Is(rt => rt.UserId == userId)), Times.Exactly(2)); - _refreshTokenStore.Where(rt => rt.UserId == userId).Should().HaveCount(2); - _refreshTokenStore[0].Id.Should().NotBe(_refreshTokenStore[1].Id); + var refreshTokens = await context.RefreshTokens.Where(rt => rt.UserId == userId).ToListAsync(); + refreshTokens.Count.Should().Be(2); + refreshTokens[0].Id.Should().NotBe(refreshTokens[1].Id); } [Fact] - public void CreateSession_WhenConfiguredWithIssuerAndAudience_EmbedsClaimsInJwt() + public async Task CreateSession_WhenConfiguredWithIssuerAndAudience_EmbedsClaimsInJwt() { // Arrange var userId = Guid.NewGuid(); var expectedIssuer = "custom issuer"; var expectedAudience = "custom audience"; var options = CreateOptions(issuer: expectedIssuer, audience: expectedAudience); + using var context = CreateContext(); - var sut = new JwtUserSessionService(options, _dbContextMock.Object, _fakeTimeProvider); + var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); // Act var result = sut.CreateSession(userId); + await context.SaveChangesAsync(); // Assert var handler = new JsonWebTokenHandler(); @@ -206,5 +226,4 @@ public void CreateSession_WhenConfiguredWithIssuerAndAudience_EmbedsClaimsInJwt( jwtToken.Issuer.Should().Be(expectedIssuer); jwtToken.Audiences.Should().Contain(expectedAudience); } - } \ No newline at end of file diff --git a/server/PutWiki.UnitTests/PutWiki.UnitTests.csproj b/server/PutWiki.UnitTests/PutWiki.UnitTests.csproj index bc8b213..fa50b1e 100644 --- a/server/PutWiki.UnitTests/PutWiki.UnitTests.csproj +++ b/server/PutWiki.UnitTests/PutWiki.UnitTests.csproj @@ -6,6 +6,7 @@ + diff --git a/server/PutWiki.UnitTests/packages.lock.json b/server/PutWiki.UnitTests/packages.lock.json index c22cb60..01b55a4 100644 --- a/server/PutWiki.UnitTests/packages.lock.json +++ b/server/PutWiki.UnitTests/packages.lock.json @@ -23,6 +23,21 @@ "Microsoft.Extensions.Logging.Abstractions": "2.1.1" } }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "Direct", + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "CqPgFVpKjdNjPHO1fqK5Uw/h7RfY6LyjPko9cLiXTBkxUhjO0Bt48tnF/ddw5PL2k5JaR8myDcyogcONY7juLg==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.13", + "Microsoft.Extensions.Caching.Memory": "10.0.13", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.13", + "Microsoft.Extensions.DependencyModel": "10.0.13", + "Microsoft.Extensions.Logging": "10.0.13", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.13", + "SQLitePCLRaw.core": "2.1.13" + } + }, "Microsoft.Extensions.TimeProvider.Testing": { "type": "Direct", "requested": "[10.11.0, )", @@ -85,88 +100,115 @@ "resolved": "18.11.0", "contentHash": "MhN9wpvL1Es4hfIYslrsVgIsUdDFxuOBTbAl9mGIMvFo8PP5CLf4DrGdNrfwWSyn0lqi6sssm+I/qkkLL3MU+g==" }, + "Microsoft.Data.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.13", + "contentHash": "Gge8XsSCOwcbkbdmt2UDAe6kIv9Q0vA6yb3wsTStjjexaMFLTWfc/1gH4kbhJ+zWiXRTRA4lG2sOQNVfir204w==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.13" + } + }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg==" + "resolved": "10.0.13", + "contentHash": "ARw+tZ3eiTosnK5nJPUGEzeUQcjyqCqs3qKA3lPtIRH5yJo3qIgxm7AvkZVIT9iWkS2083UsWe3Rj1MysW3p5A==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ==" + "resolved": "10.0.13", + "contentHash": "zBx6VeEz/hkhlm0BJ2V+9xebUuDDIszcKvurJceQHuZPEXuXubrL6Lneo9VZiliRyJbQlB2oNKLkJXd3gHteQA==" + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.13", + "contentHash": "zXJAJOS34unH9sWP6ywyUmaZ/yeHy0/CljvdsHvzhN/rXHRBXsPRYymTU65my0rGoa60mh7HUXVPEGOxfsiWSg==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.13", + "Microsoft.EntityFrameworkCore.Relational": "10.0.13", + "Microsoft.Extensions.Caching.Memory": "10.0.13", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.13", + "Microsoft.Extensions.DependencyModel": "10.0.13", + "Microsoft.Extensions.Logging": "10.0.13", + "SQLitePCLRaw.core": "2.1.13" + } }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "sI3A6MUAyZwccLnwq0cXuRmC3WBtXieimBWafXSfhRJ4jnM2uGVXrqImDDVyW27u4vVaTOGlJxNJd8d1FGvCNQ==", + "resolved": "10.0.13", + "contentHash": "UOJqcdNMoFcV/5RUcOlVKq9pPmbrcmbpLx5iYz9QtrmMcWRQzlL5Q/NZRfbPPLNyIEc1HWjjs5Z7nIwqewU1sg==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.12" + "Microsoft.Extensions.Primitives": "10.0.13" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "fhC3DHcBcFF4bXvHIxFtXJYNut7q7ZItqNFcgWfrIgmpVImlcEKNHhE7ztpNSPJYMHYYStS5QGND2I5mC26yeA==", + "resolved": "10.0.13", + "contentHash": "q+At65RACuCtGwUDVI08TmmrC73YFouimMkJAtlhsGhFcU/dVpqYEKpNBOF9TUxkJ6aefEOShqe7StLK7q3NqA==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.12", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", - "Microsoft.Extensions.Logging.Abstractions": "10.0.12", - "Microsoft.Extensions.Options": "10.0.12", - "Microsoft.Extensions.Primitives": "10.0.12" + "Microsoft.Extensions.Caching.Abstractions": "10.0.13", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.13", + "Microsoft.Extensions.Logging.Abstractions": "10.0.13", + "Microsoft.Extensions.Options": "10.0.13", + "Microsoft.Extensions.Primitives": "10.0.13" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "resolved": "10.0.13", + "contentHash": "DY4C2qzTt2lOIY3VoscBH/TCwTolI5DKOfgJLuI+k1QUvRNT5vTMS58AOerNiihdVRStO0wEpLeRWA8jytzHBQ==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.12" + "Microsoft.Extensions.Primitives": "10.0.13" } }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "resolved": "10.0.13", + "contentHash": "4tnWU9vrU/A8NwYe9Z6wvieZ0EBb/uM82LAT+edNhdepOBWREZ7M7mzb941EIDPJaeOFdbVrDd5+7s9e89U6bQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.13" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + "resolved": "10.0.13", + "contentHash": "G2GrGWNAPZBsii2F6AU8n+JT2tC1/I0Ml/e9ZyNCgPjf3hZqronP7o0X91aAynCmVeae8KkSIUNXXeXCNRmdVQ==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.13", + "contentHash": "6TyllZsToy3S/Ziu67GddljRTttuVJ8bbrbVUrsdHqH5678Oax5MpaEubxLoPoWLvgUBYo4KfcAig0mkBtktXQ==" }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", + "resolved": "10.0.13", + "contentHash": "fqbiPy4nUp4H2XHKAVQXKN2y3T2h4cFIx1DAAgE6iY9OHkN/OA1cKFi+geJKRWGuX6pS5SFbsjIPWHJqrxE0eg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.12", - "Microsoft.Extensions.Logging.Abstractions": "10.0.12", - "Microsoft.Extensions.Options": "10.0.12" + "Microsoft.Extensions.DependencyInjection": "10.0.13", + "Microsoft.Extensions.Logging.Abstractions": "10.0.13", + "Microsoft.Extensions.Options": "10.0.13" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "resolved": "10.0.13", + "contentHash": "aSZEhEig4fvNiw2zs8Z6NyDlniKtq6YjcF3DyHuqTEHcZ2VKA/6itMPZ+dW+yWABWExlDcQwwdbqX1Rcb7W8WQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.13" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", + "resolved": "10.0.13", + "contentHash": "tmMJgdrE4uhf1JcNs9CV9moAHW+hmDuvgMiPpnQxarKaUxqI2eN33sYK5S4l9Uw+rG9PJvlm4qPxMhvolTDTaQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", - "Microsoft.Extensions.Primitives": "10.0.12" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.13", + "Microsoft.Extensions.Primitives": "10.0.13" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + "resolved": "10.0.13", + "contentHash": "ZjTwxzhiw2TMEzIbUDkMF0dfDB2I4NQgIfKD0ELu9/5Mk+SRGdKZcD0/lvBrRCAPo6KN2r0BW86MRancoyJkoA==" }, "Microsoft.IdentityModel.Abstractions": { "type": "Transitive", @@ -229,6 +271,33 @@ "Microsoft.TestPlatform.ObjectModel": "18.11.0" } }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.13", + "contentHash": "NlDixlC7uHmo+ojXRgkNvN0tQtVGpLDZ3x2OpgNj+G07VHh5cXSszFDrAiC4BpUiPwkcaeuQkMV9WQ123xVtmQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.13", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.13" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.13", + "contentHash": "2joofoinuHBFs0nMX3nKuXgxpXryRkCOdM2Ua6HBqQLLy6IW+5f28ZI+HAvMyF8vG7XfNz/lwfBfE6V4juru8Q==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.13", + "contentHash": "D2UAuiGxFYBqeRXjjjGgvJ5UQifzn6wefmSqeKU7XcNGKLbJRP6X7kcPZ6opOBJGdCIlgbSxTvDpBbLvsN594A==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.13", + "contentHash": "ZU60dSqoSNRe49KcQDUmOVgdRtjITOssVXFtiISs+zJXDI4D2Sndwpn2NOtzDQNPu0ZAvpoLPQ+GLKv6iEZn8Q==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.13" + } + }, "System.Diagnostics.EventLog": { "type": "Transitive", "resolved": "6.0.0", @@ -353,26 +422,26 @@ }, "Microsoft.EntityFrameworkCore": { "type": "CentralTransitive", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==", + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "u/z4nTQUJ5nJEXAQekgQEQZkh0delp4gL+ESHn7Z6k037c8HC5mSNu1/YdBgxGm7DtLjhOxktNl+aQMzw6wN7A==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.12", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.12", - "Microsoft.Extensions.Caching.Memory": "10.0.12", - "Microsoft.Extensions.Logging": "10.0.12" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.13", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.13", + "Microsoft.Extensions.Caching.Memory": "10.0.13", + "Microsoft.Extensions.Logging": "10.0.13" } }, "Microsoft.EntityFrameworkCore.Relational": { "type": "CentralTransitive", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.12", - "Microsoft.Extensions.Caching.Memory": "10.0.12", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", - "Microsoft.Extensions.Logging": "10.0.12" + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "QU/sbTtDR5wVAJRVecC1GwM7mwH8bvKOZNm+lulzMr5rhjdb8DDEFIqqdhF08JIo51DPv5uQdZ1nXoZsD5pTag==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.13", + "Microsoft.Extensions.Caching.Memory": "10.0.13", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.13", + "Microsoft.Extensions.Logging": "10.0.13" } }, "Moq": { From a27e5649139e31b6cb869ce360bbe216fbb0aa9b Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Sun, 25 Oct 2026 01:11:45 +0200 Subject: [PATCH 21/33] docs: update README.md --- server/README.md | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/server/README.md b/server/README.md index 078ef5f..e180a86 100644 --- a/server/README.md +++ b/server/README.md @@ -40,8 +40,40 @@ Our app provides a way to users to authenticate using USOS. It allows us to acce 2. Copy values from `.env.example` and fill in your `UsosOAuth__ConsumerKey`, `UsosOAuth__ConsumerSecret` that we provided you and any other variables. ### JWT -here is description of our own token used by frontend to authenticate with backend. - +Authentication flow including USOS OAuth shown as sequence diagram for backend API client: +```mermaid +sequenceDiagram + autonumber + actor Client as Client + participant API as PutWiki Auth API + participant DB as Database + participant USOS as External USOS OAuth + + rect + Note over Client, USOS: Step 1: login (new/existing user) & OAuth authorization flow + Client->>API: GET /api/auth/login + API->>USOS: Request authorization URL + USOS-->>API: Return external auth URL + API-->>Client: 302 Found (redirect to USOS login page) + Client->>USOS: User logs in with USOS & grants permissions (when necessary) + USOS-->>API: Redirect to /api/auth/callback (with oauth tokens) + end + + rect + Note over API, DB: Step 2: callback & user provisioning flow + API->>DB: Provision user (if new) & persist refresh token + DB-->>API: Operations done successfully + API->>Client: 200 OK + set HttpOnly cookies (access & refresh tokens) + end + + rect + Note over Client, DB: Step 3: token refresh flow + Client->>API: GET /api/auth/refresh (sends refresh token cookie) + API->>DB: Validate token, revoke old token, store new session + DB-->>API: Operations done successfully + API->>Client: 200 OK + set new HttpOnly cookies + end +``` ## Configuration In ASP.NET Core app configuration is loaded in specific order and is read using variety of configuration sources: settings files, environment variables, etc. See [this](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-10.0) for more details. From 5c986e3b7ead7b6c14721c7d20eab068b3ccc687 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Sun, 25 Oct 2026 01:22:34 +0200 Subject: [PATCH 22/33] test(JwtUserSessionServiceTest): cover logic in RefreshSession method --- .../Auth/JwtUserSessionService.cs | 7 +- .../Auth/JwtUserSessionServiceTest.cs | 150 ++++++++++++++++++ 2 files changed, 152 insertions(+), 5 deletions(-) diff --git a/server/Infrastructure/Auth/JwtUserSessionService.cs b/server/Infrastructure/Auth/JwtUserSessionService.cs index 9c14a8b..39e78b0 100644 --- a/server/Infrastructure/Auth/JwtUserSessionService.cs +++ b/server/Infrastructure/Auth/JwtUserSessionService.cs @@ -43,12 +43,9 @@ public JwtTokenPair CreateSession(Guid userId) public async Task RefreshSession(string refreshToken) { var storedTokenObject = await dbContext.RefreshTokens - .FirstOrDefaultAsync(r => - r.Token == refreshToken && - !r.IsRevoked && - r.ExpiresAt > timeProvider.GetUtcNow()); + .FirstOrDefaultAsync(r => r.Token == refreshToken && !r.IsRevoked); - if (storedTokenObject is null) + if (storedTokenObject is null || storedTokenObject.ExpiresAt <= timeProvider.GetUtcNow()) { return null; } diff --git a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs index 596db4d..1a21c97 100644 --- a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs +++ b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs @@ -226,4 +226,154 @@ public async Task CreateSession_WhenConfiguredWithIssuerAndAudience_EmbedsClaims jwtToken.Issuer.Should().Be(expectedIssuer); jwtToken.Audiences.Should().Contain(expectedAudience); } + + [Fact] + public async Task RefreshSession_GivenValidRefreshToken_ShouldRevokeIt() + { + // Arrange + var userId = Guid.NewGuid(); + var refreshTokenString = "valid-refresh-token"; + var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); + _fakeTimeProvider.SetUtcNow(fixedTime); + using var context = CreateContext(); + + var refreshToken = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = userId, + Token = refreshTokenString, + CreatedAt = fixedTime.AddDays(-1), + ExpiresAt = fixedTime.AddDays(5), + IsRevoked = false + }; + context.Add(refreshToken); + await context.SaveChangesAsync(); + + var options = CreateOptions(); + var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); + + // Act + await sut.RefreshSession(refreshTokenString); + + // Assert + refreshToken.IsRevoked.Should().BeTrue(); + } + + + [Fact] + public async Task RefreshSession_GivenValidRefreshToken_ShouldReturnNewTokenPair() + { + // Arrange + var userId = Guid.NewGuid(); + var refreshTokenString = "valid-refresh-token"; + var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); + _fakeTimeProvider.SetUtcNow(fixedTime); + using var context = CreateContext(); + + var refreshToken = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = userId, + Token = refreshTokenString, + CreatedAt = fixedTime.AddDays(-1), + ExpiresAt = fixedTime.AddDays(5), + IsRevoked = false + }; + context.Add(refreshToken); + await context.SaveChangesAsync(); + + var options = CreateOptions(); + var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); + + // Act + var result = await sut.RefreshSession(refreshTokenString); + + // Assert + result.Should().NotBeNull(); + result.AccessToken.Should().NotBeNullOrWhiteSpace(); + result.RefreshToken.Should().NotBeNullOrWhiteSpace(); + result.RefreshToken.Should().NotBe(refreshTokenString); + } + + [Fact] + public async Task RefreshSession_GivenInvalidRefreshTokenString_ShouldReturnNull() + { + // Arrange + var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); + _fakeTimeProvider.SetUtcNow(fixedTime); + using var context = CreateContext(); + + var options = CreateOptions(); + var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); + + // Act + var result = await sut.RefreshSession("non-existent-token"); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task RefreshSession_GivenRevokedRefreshToken_ShouldReturnNull() + { + // Arrange + var userId = Guid.NewGuid(); + var refreshTokenString = "revoked-refresh-token"; + var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); + _fakeTimeProvider.SetUtcNow(fixedTime); + using var context = CreateContext(); + + var refreshToken = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = userId, + Token = refreshTokenString, + CreatedAt = fixedTime.AddDays(-1), + ExpiresAt = fixedTime.AddDays(5), + IsRevoked = true + }; + context.Add(refreshToken); + await context.SaveChangesAsync(); + + var options = CreateOptions(); + var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); + + // Act + var result = await sut.RefreshSession(refreshTokenString); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task RefreshSession_GivenExpiredRefreshToken_ShouldReturnNull() + { + // Arrange + var userId = Guid.NewGuid(); + var refreshTokenString = "expired-refresh-token"; + var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); + _fakeTimeProvider.SetUtcNow(fixedTime); + using var context = CreateContext(); + + var refreshToken = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = userId, + Token = refreshTokenString, + CreatedAt = fixedTime.AddDays(-10), + ExpiresAt = fixedTime.AddDays(-1), + IsRevoked = false + }; + context.Add(refreshToken); + await context.SaveChangesAsync(); + + var options = CreateOptions(); + var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); + + // Act + var result = await sut.RefreshSession(refreshTokenString); + + // Assert + result.Should().BeNull(); + } } \ No newline at end of file From 9a584b13cdcc88d84ed4157f842656cc7fc5b17c Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Mon, 2 Nov 2026 20:58:08 +0100 Subject: [PATCH 23/33] refactor(AppDbContext): load automatically all entity type configs --- server/Infrastructure/AppDbContext.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/server/Infrastructure/AppDbContext.cs b/server/Infrastructure/AppDbContext.cs index 10e57b6..7d24491 100644 --- a/server/Infrastructure/AppDbContext.cs +++ b/server/Infrastructure/AppDbContext.cs @@ -19,11 +19,8 @@ public class AppDbContext(DbContextOptions options) : DbContext(op protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - modelBuilder.HasPostgresExtension("citext"); - - new UserEntityTypeConfiguration().Configure(modelBuilder.Entity()); - new AcademicTeacherEntityTypeConfiguration().Configure(modelBuilder.Entity()); - new RefreshTokenEntityTypeConfiguration().Configure(modelBuilder.Entity()); + + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); } } \ No newline at end of file From 88d03a1c3b1d2a3b5200932eaab20c5f59e98f14 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Mon, 2 Nov 2026 21:00:35 +0100 Subject: [PATCH 24/33] refactor: define 1:n relationship between user and refresh token --- .../RefreshTokenEntityTypeConfiguration.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/Infrastructure/EntitiesConfiguration/RefreshTokenEntityTypeConfiguration.cs b/server/Infrastructure/EntitiesConfiguration/RefreshTokenEntityTypeConfiguration.cs index bec02f8..89d61ed 100644 --- a/server/Infrastructure/EntitiesConfiguration/RefreshTokenEntityTypeConfiguration.cs +++ b/server/Infrastructure/EntitiesConfiguration/RefreshTokenEntityTypeConfiguration.cs @@ -1,4 +1,6 @@ -using Infrastructure.Auth; +using Domain.Users; + +using Infrastructure.Auth; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -15,5 +17,11 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => x.Token).IsUnique(); builder.HasIndex(x => x.UserId); + + builder.HasOne() + .WithMany() + .HasForeignKey(x => x.UserId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); } } \ No newline at end of file From f2a137cbc87bb180911dff313182bc4d14c8d705 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Fri, 6 Nov 2026 23:18:17 +0100 Subject: [PATCH 25/33] feat(JwtUserSessionService): revoke all refresh tokens of user in case of theft --- .../Auth/JwtUserSessionService.cs | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/server/Infrastructure/Auth/JwtUserSessionService.cs b/server/Infrastructure/Auth/JwtUserSessionService.cs index 39e78b0..27bcb24 100644 --- a/server/Infrastructure/Auth/JwtUserSessionService.cs +++ b/server/Infrastructure/Auth/JwtUserSessionService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Security.Claims; using System.Security.Cryptography; using System.Text; @@ -10,18 +11,23 @@ using Infrastructure.Auth.Configuration; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; namespace Infrastructure.Auth; -public sealed class JwtUserSessionService( +public sealed partial class JwtUserSessionService( IOptions settings, AppDbContext dbContext, - TimeProvider timeProvider + TimeProvider timeProvider, + ILogger logger ) : IUserSessionService { + /** + * NOTE: after calling this method use IUnitOfWork to persist changes + */ public JwtTokenPair CreateSession(Guid userId) { var accessToken = GenerateAccessToken(userId); @@ -40,16 +46,31 @@ public JwtTokenPair CreateSession(Guid userId) return new JwtTokenPair(accessToken, refreshToken.Token); } + /** + * NOTE: after calling this method use IUnitOfWork to persist changes + */ public async Task RefreshSession(string refreshToken) { var storedTokenObject = await dbContext.RefreshTokens - .FirstOrDefaultAsync(r => r.Token == refreshToken && !r.IsRevoked); + .FirstOrDefaultAsync(r => r.Token == refreshToken); if (storedTokenObject is null || storedTokenObject.ExpiresAt <= timeProvider.GetUtcNow()) { return null; } + if (storedTokenObject.IsRevoked) + { + // NOTE: due to high severity it runs immediate batch update skipping ef core change tracker + await dbContext.RefreshTokens + .Where(r => r.UserId == storedTokenObject.UserId && !r.IsRevoked) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.IsRevoked, true)); + + LogRefreshTokenTheftOccured(storedTokenObject.UserId.ToString()); + + return null; + } + storedTokenObject.IsRevoked = true; return CreateSession(storedTokenObject.UserId); @@ -78,4 +99,7 @@ private string GenerateAccessToken(Guid userId) return new JsonWebTokenHandler().CreateToken(tokenDescriptor); } + + [LoggerMessage(LogLevel.Critical, "Tried to refresh session of user with ID: {userId} with refresh token that is already revoked")] + partial void LogRefreshTokenTheftOccured(string userId); } \ No newline at end of file From 27f0b8a8a7b037a8114de312b4ea79d76ba76af2 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Fri, 6 Nov 2026 23:21:06 +0100 Subject: [PATCH 26/33] test(JwtUserSessionServiceTest): cover new logic & add fixture --- .../Auth/JwtUserSessionServiceTest.cs | 190 +++++++----------- .../Auth/JwtUserSessionServiceTestFixture.cs | 82 ++++++++ 2 files changed, 154 insertions(+), 118 deletions(-) create mode 100644 server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTestFixture.cs diff --git a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs index 1a21c97..d017dc6 100644 --- a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs +++ b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs @@ -1,5 +1,4 @@ using System; -using System.Data.Common; using System.Linq; using System.Threading.Tasks; @@ -7,11 +6,8 @@ using Infrastructure; using Infrastructure.Auth; -using Infrastructure.Auth.Configuration; -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using Microsoft.IdentityModel.JsonWebTokens; @@ -20,66 +16,36 @@ namespace PutWiki.UnitTests.Infrastructure.Auth; public sealed class JwtUserSessionServiceTest : IDisposable { private const string ValidSecretKey = "secret-key-that-is-32-bytes-long"; - private const string ValidIssuer = "putwiki-api"; - private const string ValidAudience = "putwiki-frontend"; + private const string ValidIssuer = "valid-issuer"; + private const string ValidAudience = "valid-audience"; - private readonly FakeTimeProvider _fakeTimeProvider; - - private readonly DbConnection _connection; - private readonly DbContextOptions _contextOptions; - - public JwtUserSessionServiceTest() - { - _fakeTimeProvider = new FakeTimeProvider(); - - _connection = new SqliteConnection("Filename=:memory:"); - _connection.Open(); - - _contextOptions = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - - using var context = CreateContext(); - context.Database.EnsureCreated(); - } - - AppDbContext CreateContext() => new AppDbContext(_contextOptions); + private readonly JwtUserSessionServiceTestFixture _fixture = new(); + private readonly FakeTimeProvider _fakeTimeProvider = new(); public void Dispose() { - _connection.Dispose(); + _fixture.Dispose(); } - private static IOptions CreateOptions( + private (JwtUserSessionService Sut, AppDbContext Context) CreateSut( string secretKey = ValidSecretKey, string issuer = ValidIssuer, string audience = ValidAudience, TimeSpan? accessTokenLifetime = null, TimeSpan? refreshTokenLifetime = null) { - var settings = new JwtSettings - { - SecretKey = secretKey, - Issuer = issuer, - Audience = audience, - AccessTokenLifetime = accessTokenLifetime ?? TimeSpan.FromMinutes(30), - RefreshTokenLifetime = refreshTokenLifetime ?? TimeSpan.FromDays(7) - }; - - return Options.Create(settings); + return _fixture.CreateSut(_fakeTimeProvider, secretKey, issuer, audience, accessTokenLifetime, refreshTokenLifetime); } [Fact] public async Task CreateSession_GivenValidUserId_ReturnsValidJwtAndRefreshTokenPair() { // Arrange - var userId = Guid.NewGuid(); - var options = CreateOptions(); - using var context = CreateContext(); - var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var (sut, context) = CreateSut(); // Act - var result = sut.CreateSession(userId); + var result = sut.CreateSession(user.Id); await context.SaveChangesAsync(); // Assert @@ -89,26 +55,23 @@ public async Task CreateSession_GivenValidUserId_ReturnsValidJwtAndRefreshTokenP var handler = new JsonWebTokenHandler(); var jwtToken = handler.ReadJsonWebToken(result.AccessToken); - jwtToken.Subject.Should().Be(userId.ToString()); + jwtToken.Subject.Should().Be(user.Id.ToString()); } [Fact] public async Task CreateSession_GivenValidUserId_PersistsRefreshToken() { // Arrange - var userId = Guid.NewGuid(); - var options = CreateOptions(); - using var context = CreateContext(); - - var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var (sut, context) = CreateSut(); // Act - var result = sut.CreateSession(userId); + var result = sut.CreateSession(user.Id); await context.SaveChangesAsync(); // Assert var refreshToken = await context.RefreshTokens.SingleAsync(rt => - rt.UserId == userId && + rt.UserId == user.Id && rt.Token == result.RefreshToken && rt.Id != Guid.Empty); @@ -119,18 +82,15 @@ public async Task CreateSession_GivenValidUserId_PersistsRefreshToken() public async Task CreateSession_GivenConfiguredLifetimes_SetsCorrectAccessTokenExpirationTime() { // Arrange - var userId = Guid.NewGuid(); + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); var accessTokenLifetime = TimeSpan.FromMinutes(45); - var options = CreateOptions(accessTokenLifetime: accessTokenLifetime); - using var context = CreateContext(); - var fixedTime = new DateTimeOffset(2026, 10, 22, 23, 0, 0, TimeSpan.Zero); _fakeTimeProvider.SetUtcNow(fixedTime); - var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); + var (sut, context) = CreateSut(accessTokenLifetime: accessTokenLifetime); // Act - var result = sut.CreateSession(userId); + var result = sut.CreateSession(user.Id); await context.SaveChangesAsync(); // Assert @@ -145,22 +105,19 @@ public async Task CreateSession_GivenConfiguredLifetimes_SetsCorrectAccessTokenE public async Task CreateSession_GivenConfiguredLifetimes_SetsCorrectRefreshTokenExpirationAndCreationDates() { // Arrange - var userId = Guid.NewGuid(); + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); var refreshTokenLifetime = TimeSpan.FromDays(14); - var options = CreateOptions(refreshTokenLifetime: refreshTokenLifetime); - using var context = CreateContext(); - var fixedTime = new DateTimeOffset(2026, 10, 22, 23, 0, 0, TimeSpan.Zero); _fakeTimeProvider.SetUtcNow(fixedTime); - var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); + var (sut, context) = CreateSut(refreshTokenLifetime: refreshTokenLifetime); // Act - sut.CreateSession(userId); + sut.CreateSession(user.Id); await context.SaveChangesAsync(); // Assert - var savedRefreshToken = await context.RefreshTokens.SingleAsync(rt => rt.UserId == userId); + var savedRefreshToken = await context.RefreshTokens.SingleAsync(rt => rt.UserId == user.Id); savedRefreshToken.CreatedAt.Should().Be(fixedTime); savedRefreshToken.ExpiresAt.Should().Be(fixedTime.Add(refreshTokenLifetime)); } @@ -169,14 +126,12 @@ public async Task CreateSession_GivenConfiguredLifetimes_SetsCorrectRefreshToken public async Task CreateSession_WhenCalledMultipleTimesForSameUser_GeneratesUniqueRefreshTokens() { // Arrange - var userId = Guid.NewGuid(); - var options = CreateOptions(); - using var context = CreateContext(); - var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var (sut, context) = CreateSut(); // Act - var session1 = sut.CreateSession(userId); - var session2 = sut.CreateSession(userId); + var session1 = sut.CreateSession(user.Id); + var session2 = sut.CreateSession(user.Id); await context.SaveChangesAsync(); // Assert @@ -187,18 +142,16 @@ public async Task CreateSession_WhenCalledMultipleTimesForSameUser_GeneratesUniq public async Task CreateSession_WhenCalledMultipleTimesForSameUser_StoresAllRefreshTokensNotOverwritesThem() { // Arrange - var userId = Guid.NewGuid(); - var options = CreateOptions(); - using var context = CreateContext(); - var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var (sut, context) = CreateSut(); // Act - sut.CreateSession(userId); - sut.CreateSession(userId); + sut.CreateSession(user.Id); + sut.CreateSession(user.Id); await context.SaveChangesAsync(); // Assert - var refreshTokens = await context.RefreshTokens.Where(rt => rt.UserId == userId).ToListAsync(); + var refreshTokens = await context.RefreshTokens.Where(rt => rt.UserId == user.Id).ToListAsync(); refreshTokens.Count.Should().Be(2); refreshTokens[0].Id.Should().NotBe(refreshTokens[1].Id); } @@ -207,16 +160,13 @@ public async Task CreateSession_WhenCalledMultipleTimesForSameUser_StoresAllRefr public async Task CreateSession_WhenConfiguredWithIssuerAndAudience_EmbedsClaimsInJwt() { // Arrange - var userId = Guid.NewGuid(); + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); var expectedIssuer = "custom issuer"; var expectedAudience = "custom audience"; - var options = CreateOptions(issuer: expectedIssuer, audience: expectedAudience); - using var context = CreateContext(); - - var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); + var (sut, context) = CreateSut(issuer: expectedIssuer, audience: expectedAudience); // Act - var result = sut.CreateSession(userId); + var result = sut.CreateSession(user.Id); await context.SaveChangesAsync(); // Assert @@ -231,16 +181,17 @@ public async Task CreateSession_WhenConfiguredWithIssuerAndAudience_EmbedsClaims public async Task RefreshSession_GivenValidRefreshToken_ShouldRevokeIt() { // Arrange - var userId = Guid.NewGuid(); + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); var refreshTokenString = "valid-refresh-token"; var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); _fakeTimeProvider.SetUtcNow(fixedTime); - using var context = CreateContext(); + + var (sut, context) = CreateSut(); var refreshToken = new RefreshToken { Id = Guid.NewGuid(), - UserId = userId, + UserId = user.Id, Token = refreshTokenString, CreatedAt = fixedTime.AddDays(-1), ExpiresAt = fixedTime.AddDays(5), @@ -249,9 +200,6 @@ public async Task RefreshSession_GivenValidRefreshToken_ShouldRevokeIt() context.Add(refreshToken); await context.SaveChangesAsync(); - var options = CreateOptions(); - var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); - // Act await sut.RefreshSession(refreshTokenString); @@ -259,21 +207,21 @@ public async Task RefreshSession_GivenValidRefreshToken_ShouldRevokeIt() refreshToken.IsRevoked.Should().BeTrue(); } - [Fact] public async Task RefreshSession_GivenValidRefreshToken_ShouldReturnNewTokenPair() { // Arrange - var userId = Guid.NewGuid(); + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); var refreshTokenString = "valid-refresh-token"; var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); _fakeTimeProvider.SetUtcNow(fixedTime); - using var context = CreateContext(); + + var (sut, context) = CreateSut(); var refreshToken = new RefreshToken { Id = Guid.NewGuid(), - UserId = userId, + UserId = user.Id, Token = refreshTokenString, CreatedAt = fixedTime.AddDays(-1), ExpiresAt = fixedTime.AddDays(5), @@ -282,9 +230,6 @@ public async Task RefreshSession_GivenValidRefreshToken_ShouldReturnNewTokenPair context.Add(refreshToken); await context.SaveChangesAsync(); - var options = CreateOptions(); - var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); - // Act var result = await sut.RefreshSession(refreshTokenString); @@ -301,10 +246,8 @@ public async Task RefreshSession_GivenInvalidRefreshTokenString_ShouldReturnNull // Arrange var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); _fakeTimeProvider.SetUtcNow(fixedTime); - using var context = CreateContext(); - var options = CreateOptions(); - var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); + var (sut, _) = CreateSut(); // Act var result = await sut.RefreshSession("non-existent-token"); @@ -314,34 +257,47 @@ public async Task RefreshSession_GivenInvalidRefreshTokenString_ShouldReturnNull } [Fact] - public async Task RefreshSession_GivenRevokedRefreshToken_ShouldReturnNull() + public async Task RefreshSession_GivenRevokedRefreshToken_ShouldRevokeAllUserTokensAndReturnNull() { // Arrange - var userId = Guid.NewGuid(); - var refreshTokenString = "revoked-refresh-token"; + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var notRevokedRefreshTokenString = "refresh-token-1"; + var revokedRefreshTokenString = "refresh-token-2"; + var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); _fakeTimeProvider.SetUtcNow(fixedTime); - using var context = CreateContext(); - var refreshToken = new RefreshToken + var (sut, context) = CreateSut(); + + var notRevokedRefreshToken = new RefreshToken { Id = Guid.NewGuid(), - UserId = userId, - Token = refreshTokenString, + UserId = user.Id, + Token = notRevokedRefreshTokenString, + CreatedAt = fixedTime.AddDays(-1), + ExpiresAt = fixedTime.AddDays(5), + IsRevoked = false + }; + var revokedRefreshToken = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = user.Id, + Token = revokedRefreshTokenString, CreatedAt = fixedTime.AddDays(-1), ExpiresAt = fixedTime.AddDays(5), IsRevoked = true }; - context.Add(refreshToken); + context.Add(notRevokedRefreshToken); + context.Add(revokedRefreshToken); await context.SaveChangesAsync(); - var options = CreateOptions(); - var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); - // Act - var result = await sut.RefreshSession(refreshTokenString); + var result = await sut.RefreshSession(revokedRefreshTokenString); // Assert + var refreshTokens = await context.RefreshTokens.Where(rt => rt.UserId == user.Id && rt.IsRevoked).ToListAsync(); + refreshTokens.Count.Should().Be(2); + result.Should().BeNull(); } @@ -349,16 +305,17 @@ public async Task RefreshSession_GivenRevokedRefreshToken_ShouldReturnNull() public async Task RefreshSession_GivenExpiredRefreshToken_ShouldReturnNull() { // Arrange - var userId = Guid.NewGuid(); + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); var refreshTokenString = "expired-refresh-token"; var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); _fakeTimeProvider.SetUtcNow(fixedTime); - using var context = CreateContext(); + + var (sut, context) = CreateSut(); var refreshToken = new RefreshToken { Id = Guid.NewGuid(), - UserId = userId, + UserId = user.Id, Token = refreshTokenString, CreatedAt = fixedTime.AddDays(-10), ExpiresAt = fixedTime.AddDays(-1), @@ -367,9 +324,6 @@ public async Task RefreshSession_GivenExpiredRefreshToken_ShouldReturnNull() context.Add(refreshToken); await context.SaveChangesAsync(); - var options = CreateOptions(); - var sut = new JwtUserSessionService(options, context, _fakeTimeProvider); - // Act var result = await sut.RefreshSession(refreshTokenString); diff --git a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTestFixture.cs b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTestFixture.cs new file mode 100644 index 0000000..943ae92 --- /dev/null +++ b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTestFixture.cs @@ -0,0 +1,82 @@ +using System; +using System.Data.Common; +using System.Threading.Tasks; + +using Domain.Users; + +using Infrastructure; +using Infrastructure.Auth; +using Infrastructure.Auth.Configuration; + +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace PutWiki.UnitTests.Infrastructure.Auth; + +public sealed class JwtUserSessionServiceTestFixture : IDisposable +{ + private const string DefaultSecretKey = "secret-key-that-is-32-bytes-long"; + private const string DefaultIssuer = "default-issuer"; + private const string DefaultAudience = "default-audience"; + + private readonly DbConnection _connection; + private readonly DbContextOptions _contextOptions; + + public JwtUserSessionServiceTestFixture() + { + _connection = new SqliteConnection("Filename=:memory:"); + _connection.Open(); + + _contextOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using var context = CreateContext(); + context.Database.EnsureCreated(); + } + + public AppDbContext CreateContext() => new AppDbContext(_contextOptions); + + public (JwtUserSessionService Sut, AppDbContext Context) CreateSut( + TimeProvider timeProvider, + string secretKey = DefaultSecretKey, + string issuer = DefaultIssuer, + string audience = DefaultAudience, + TimeSpan? accessTokenLifetime = null, + TimeSpan? refreshTokenLifetime = null) + { + var settings = new JwtSettings + { + SecretKey = secretKey, + Issuer = issuer, + Audience = audience, + AccessTokenLifetime = accessTokenLifetime ?? TimeSpan.FromMinutes(30), + RefreshTokenLifetime = refreshTokenLifetime ?? TimeSpan.FromDays(7) + }; + + var context = CreateContext(); + var sut = new JwtUserSessionService( + Options.Create(settings), + context, + timeProvider, + NullLogger.Instance); + + return (sut, context); + } + + public async Task CreateUserAsync(TimeProvider timeProvider, string usosId = "hashed-usos-id") + { + using var context = CreateContext(); + var user = User.Create(usosId, timeProvider.GetUtcNow()); + context.Users.Add(user); + await context.SaveChangesAsync(); + return user; + } + + public void Dispose() + { + _connection.Dispose(); + } +} \ No newline at end of file From 8941aa0ae08c03f0b0ba5c3e05dedf6ed4245273 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 12 Nov 2026 12:36:05 +0100 Subject: [PATCH 27/33] fix(JwtUserSessionService): possible concurrency issues during refreshing session --- server/Infrastructure/AppDbContext.cs | 1 - server/Infrastructure/Auth/JwtUserSessionService.cs | 9 ++++++++- .../Infrastructure/Auth/JwtUserSessionServiceTest.cs | 7 ++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/server/Infrastructure/AppDbContext.cs b/server/Infrastructure/AppDbContext.cs index 7d24491..8ff17ae 100644 --- a/server/Infrastructure/AppDbContext.cs +++ b/server/Infrastructure/AppDbContext.cs @@ -4,7 +4,6 @@ using Domain.Users; using Infrastructure.Auth; -using Infrastructure.EntitiesConfiguration; using Microsoft.EntityFrameworkCore; diff --git a/server/Infrastructure/Auth/JwtUserSessionService.cs b/server/Infrastructure/Auth/JwtUserSessionService.cs index 27bcb24..3d550b2 100644 --- a/server/Infrastructure/Auth/JwtUserSessionService.cs +++ b/server/Infrastructure/Auth/JwtUserSessionService.cs @@ -71,7 +71,14 @@ await dbContext.RefreshTokens return null; } - storedTokenObject.IsRevoked = true; + var rowsAffected = await dbContext.RefreshTokens + .Where(r => r.Token == refreshToken && !r.IsRevoked) + .ExecuteUpdateAsync(s => s + .SetProperty(r => r.IsRevoked, true)); + if (rowsAffected == 0) + { + return null; + } return CreateSession(storedTokenObject.UserId); } diff --git a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs index d017dc6..51c5b15 100644 --- a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs +++ b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs @@ -202,6 +202,7 @@ public async Task RefreshSession_GivenValidRefreshToken_ShouldRevokeIt() // Act await sut.RefreshSession(refreshTokenString); + await context.SaveChangesAsync(); // Assert refreshToken.IsRevoked.Should().BeTrue(); @@ -232,6 +233,7 @@ public async Task RefreshSession_GivenValidRefreshToken_ShouldReturnNewTokenPair // Act var result = await sut.RefreshSession(refreshTokenString); + await context.SaveChangesAsync(); // Assert result.Should().NotBeNull(); @@ -247,10 +249,11 @@ public async Task RefreshSession_GivenInvalidRefreshTokenString_ShouldReturnNull var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); _fakeTimeProvider.SetUtcNow(fixedTime); - var (sut, _) = CreateSut(); + var (sut, context) = CreateSut(); // Act var result = await sut.RefreshSession("non-existent-token"); + await context.SaveChangesAsync(); // Assert result.Should().BeNull(); @@ -293,6 +296,7 @@ public async Task RefreshSession_GivenRevokedRefreshToken_ShouldRevokeAllUserTok // Act var result = await sut.RefreshSession(revokedRefreshTokenString); + await context.SaveChangesAsync(); // Assert var refreshTokens = await context.RefreshTokens.Where(rt => rt.UserId == user.Id && rt.IsRevoked).ToListAsync(); @@ -326,6 +330,7 @@ public async Task RefreshSession_GivenExpiredRefreshToken_ShouldReturnNull() // Act var result = await sut.RefreshSession(refreshTokenString); + await context.SaveChangesAsync(); // Assert result.Should().BeNull(); From ad6387c3cdf548fc9872d34fc68cc1821f0ab81a Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 12 Nov 2026 16:00:10 +0100 Subject: [PATCH 28/33] feat: hash refresh tokens before saving in db --- server/Application/Auth/IUserSessionService.cs | 2 +- .../Infrastructure/Auth/JwtUserSessionService.cs | 14 +++++++++----- server/Infrastructure/Auth/RefreshTokenHasher.cs | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 server/Infrastructure/Auth/RefreshTokenHasher.cs diff --git a/server/Application/Auth/IUserSessionService.cs b/server/Application/Auth/IUserSessionService.cs index 4a227e9..9b3887b 100644 --- a/server/Application/Auth/IUserSessionService.cs +++ b/server/Application/Auth/IUserSessionService.cs @@ -6,5 +6,5 @@ namespace Application.Auth; public interface IUserSessionService { JwtTokenPair CreateSession(Guid userId); - Task RefreshSession(string refreshToken); + Task RefreshSession(string rawRefreshTokenString); } \ No newline at end of file diff --git a/server/Infrastructure/Auth/JwtUserSessionService.cs b/server/Infrastructure/Auth/JwtUserSessionService.cs index 3d550b2..c81b6d8 100644 --- a/server/Infrastructure/Auth/JwtUserSessionService.cs +++ b/server/Infrastructure/Auth/JwtUserSessionService.cs @@ -32,27 +32,31 @@ public JwtTokenPair CreateSession(Guid userId) { var accessToken = GenerateAccessToken(userId); + var rawRefreshTokenString = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); + var hashedRefreshTokenString = RefreshTokenHasher.CreateHash(rawRefreshTokenString); var refreshToken = new RefreshToken { Id = Guid.NewGuid(), UserId = userId, - Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)), + Token = hashedRefreshTokenString, ExpiresAt = timeProvider.GetUtcNow().Add(settings.Value.RefreshTokenLifetime), CreatedAt = timeProvider.GetUtcNow() }; dbContext.RefreshTokens.Add(refreshToken); - return new JwtTokenPair(accessToken, refreshToken.Token); + return new JwtTokenPair(accessToken, rawRefreshTokenString); } /** * NOTE: after calling this method use IUnitOfWork to persist changes */ - public async Task RefreshSession(string refreshToken) + public async Task RefreshSession(string rawRefreshTokenString) { + var hashedRefreshTokenString = RefreshTokenHasher.CreateHash(rawRefreshTokenString); + var storedTokenObject = await dbContext.RefreshTokens - .FirstOrDefaultAsync(r => r.Token == refreshToken); + .FirstOrDefaultAsync(r => r.Token == hashedRefreshTokenString); if (storedTokenObject is null || storedTokenObject.ExpiresAt <= timeProvider.GetUtcNow()) { @@ -72,7 +76,7 @@ await dbContext.RefreshTokens } var rowsAffected = await dbContext.RefreshTokens - .Where(r => r.Token == refreshToken && !r.IsRevoked) + .Where(r => r.Token == hashedRefreshTokenString && !r.IsRevoked) .ExecuteUpdateAsync(s => s .SetProperty(r => r.IsRevoked, true)); if (rowsAffected == 0) diff --git a/server/Infrastructure/Auth/RefreshTokenHasher.cs b/server/Infrastructure/Auth/RefreshTokenHasher.cs new file mode 100644 index 0000000..99738a9 --- /dev/null +++ b/server/Infrastructure/Auth/RefreshTokenHasher.cs @@ -0,0 +1,15 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Infrastructure.Auth; + +public static class RefreshTokenHasher +{ + public static string CreateHash(string rawToken) + { + var bytes = Encoding.UTF8.GetBytes(rawToken); + var hashBytes = SHA256.HashData(bytes); + return Convert.ToBase64String(hashBytes); + } +} \ No newline at end of file From 8172bdaf8ba65d30522f21f4dd02eae6eeaf4201 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 12 Nov 2026 16:01:28 +0100 Subject: [PATCH 29/33] test(JwtUserSessionServiceTest): update test to include hashing and new fixture methods --- .../Auth/JwtUserSessionServiceTest.cs | 117 ++++++++---------- .../Auth/JwtUserSessionServiceTestFixture.cs | 39 ++++++ 2 files changed, 89 insertions(+), 67 deletions(-) diff --git a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs index 51c5b15..bf36537 100644 --- a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs +++ b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs @@ -70,9 +70,10 @@ public async Task CreateSession_GivenValidUserId_PersistsRefreshToken() await context.SaveChangesAsync(); // Assert + var hashedRefreshToken = RefreshTokenHasher.CreateHash(result.RefreshToken); var refreshToken = await context.RefreshTokens.SingleAsync(rt => rt.UserId == user.Id && - rt.Token == result.RefreshToken && + rt.Token == hashedRefreshToken && rt.Id != Guid.Empty); refreshToken.Should().NotBeNull(); @@ -182,30 +183,29 @@ public async Task RefreshSession_GivenValidRefreshToken_ShouldRevokeIt() { // Arrange var user = await _fixture.CreateUserAsync(_fakeTimeProvider); - var refreshTokenString = "valid-refresh-token"; + var rawRefreshTokenString = "raw-refresh-token"; var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); _fakeTimeProvider.SetUtcNow(fixedTime); var (sut, context) = CreateSut(); - var refreshToken = new RefreshToken - { - Id = Guid.NewGuid(), - UserId = user.Id, - Token = refreshTokenString, - CreatedAt = fixedTime.AddDays(-1), - ExpiresAt = fixedTime.AddDays(5), - IsRevoked = false - }; - context.Add(refreshToken); - await context.SaveChangesAsync(); + await _fixture.CreateRefreshTokenAsync( + userId: user.Id, + timeProvider: _fakeTimeProvider, + rawTokenString: rawRefreshTokenString + ); // Act - await sut.RefreshSession(refreshTokenString); + await sut.RefreshSession(rawRefreshTokenString); await context.SaveChangesAsync(); // Assert - refreshToken.IsRevoked.Should().BeTrue(); + using var verifyContext = _fixture.CreateContext(); + + var refreshTokens = await verifyContext.RefreshTokens.Where(rt => rt.UserId == user.Id).ToListAsync(); + refreshTokens.Count.Should().Be(2); + refreshTokens.Single(rt => rt.IsRevoked).Should().NotBeNull(); + refreshTokens.Single(rt => !rt.IsRevoked).Should().NotBeNull(); } [Fact] @@ -213,33 +213,27 @@ public async Task RefreshSession_GivenValidRefreshToken_ShouldReturnNewTokenPair { // Arrange var user = await _fixture.CreateUserAsync(_fakeTimeProvider); - var refreshTokenString = "valid-refresh-token"; + var rawRefreshTokenString = "valid-refresh-token"; var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); _fakeTimeProvider.SetUtcNow(fixedTime); var (sut, context) = CreateSut(); - var refreshToken = new RefreshToken - { - Id = Guid.NewGuid(), - UserId = user.Id, - Token = refreshTokenString, - CreatedAt = fixedTime.AddDays(-1), - ExpiresAt = fixedTime.AddDays(5), - IsRevoked = false - }; - context.Add(refreshToken); - await context.SaveChangesAsync(); + await _fixture.CreateRefreshTokenAsync( + userId: user.Id, + timeProvider: _fakeTimeProvider, + rawTokenString: rawRefreshTokenString + ); // Act - var result = await sut.RefreshSession(refreshTokenString); + var result = await sut.RefreshSession(rawRefreshTokenString); await context.SaveChangesAsync(); // Assert result.Should().NotBeNull(); result.AccessToken.Should().NotBeNullOrWhiteSpace(); result.RefreshToken.Should().NotBeNullOrWhiteSpace(); - result.RefreshToken.Should().NotBe(refreshTokenString); + result.RefreshToken.Should().NotBe(rawRefreshTokenString); } [Fact] @@ -264,43 +258,36 @@ public async Task RefreshSession_GivenRevokedRefreshToken_ShouldRevokeAllUserTok { // Arrange var user = await _fixture.CreateUserAsync(_fakeTimeProvider); - var notRevokedRefreshTokenString = "refresh-token-1"; - var revokedRefreshTokenString = "refresh-token-2"; + var notRevokedRawRefreshTokenString = "refresh-token-1"; + var revokedRawRefreshTokenString = "refresh-token-2"; var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); _fakeTimeProvider.SetUtcNow(fixedTime); var (sut, context) = CreateSut(); - var notRevokedRefreshToken = new RefreshToken - { - Id = Guid.NewGuid(), - UserId = user.Id, - Token = notRevokedRefreshTokenString, - CreatedAt = fixedTime.AddDays(-1), - ExpiresAt = fixedTime.AddDays(5), - IsRevoked = false - }; - var revokedRefreshToken = new RefreshToken - { - Id = Guid.NewGuid(), - UserId = user.Id, - Token = revokedRefreshTokenString, - CreatedAt = fixedTime.AddDays(-1), - ExpiresAt = fixedTime.AddDays(5), - IsRevoked = true - }; - context.Add(notRevokedRefreshToken); - context.Add(revokedRefreshToken); - await context.SaveChangesAsync(); + await _fixture.CreateRefreshTokenAsync( + userId: user.Id, + timeProvider: _fakeTimeProvider, + rawTokenString: notRevokedRawRefreshTokenString + ); + + await _fixture.CreateRevokedRefreshTokenAsync( + userId: user.Id, + timeProvider: _fakeTimeProvider, + rawTokenString: revokedRawRefreshTokenString + ); // Act - var result = await sut.RefreshSession(revokedRefreshTokenString); + var result = await sut.RefreshSession(revokedRawRefreshTokenString); await context.SaveChangesAsync(); // Assert - var refreshTokens = await context.RefreshTokens.Where(rt => rt.UserId == user.Id && rt.IsRevoked).ToListAsync(); + using var verifyContext = _fixture.CreateContext(); + + var refreshTokens = await verifyContext.RefreshTokens.Where(rt => rt.UserId == user.Id && rt.IsRevoked).ToListAsync(); refreshTokens.Count.Should().Be(2); + refreshTokens.Should().OnlyContain(rt => rt.IsRevoked); result.Should().BeNull(); } @@ -310,26 +297,22 @@ public async Task RefreshSession_GivenExpiredRefreshToken_ShouldReturnNull() { // Arrange var user = await _fixture.CreateUserAsync(_fakeTimeProvider); - var refreshTokenString = "expired-refresh-token"; + var rawRefreshTokenString = "expired-refresh-token"; var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); _fakeTimeProvider.SetUtcNow(fixedTime); var (sut, context) = CreateSut(); - var refreshToken = new RefreshToken - { - Id = Guid.NewGuid(), - UserId = user.Id, - Token = refreshTokenString, - CreatedAt = fixedTime.AddDays(-10), - ExpiresAt = fixedTime.AddDays(-1), - IsRevoked = false - }; - context.Add(refreshToken); - await context.SaveChangesAsync(); + await _fixture.CreateRefreshTokenAsync( + userId: user.Id, + timeProvider: _fakeTimeProvider, + rawTokenString: rawRefreshTokenString, + age: TimeSpan.FromDays(5), + lifetime: TimeSpan.FromDays(2) + ); // Act - var result = await sut.RefreshSession(refreshTokenString); + var result = await sut.RefreshSession(rawRefreshTokenString); await context.SaveChangesAsync(); // Assert diff --git a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTestFixture.cs b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTestFixture.cs index 943ae92..c563c63 100644 --- a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTestFixture.cs +++ b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTestFixture.cs @@ -75,6 +75,45 @@ public async Task CreateUserAsync(TimeProvider timeProvider, string usosId return user; } + public async Task CreateRevokedRefreshTokenAsync( + Guid userId, + TimeProvider timeProvider, + string rawTokenString = "raw-refresh-token", + TimeSpan? lifetime = null, + TimeSpan? age = null) + { + return await CreateRefreshTokenAsync(userId, timeProvider, rawTokenString, true, lifetime, age); + } + + public async Task CreateRefreshTokenAsync( + Guid userId, + TimeProvider timeProvider, + string rawTokenString = "raw-refresh-token", + bool isRevoked = false, + TimeSpan? lifetime = null, + TimeSpan? age = null) + { + using var context = CreateContext(); + var now = timeProvider.GetUtcNow(); + + var createdAt = now.Subtract(age ?? TimeSpan.FromDays(1)); + var tokenLifetime = lifetime ?? TimeSpan.FromDays(7); + + var refreshToken = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = userId, + Token = RefreshTokenHasher.CreateHash(rawTokenString), + CreatedAt = createdAt, + ExpiresAt = createdAt.Add(tokenLifetime), + IsRevoked = isRevoked + }; + + context.RefreshTokens.Add(refreshToken); + await context.SaveChangesAsync(); + return refreshToken; + } + public void Dispose() { _connection.Dispose(); From 09d4885dba32d00ffd9532b7f82c628f5c4aa726 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 12 Nov 2026 16:40:52 +0100 Subject: [PATCH 30/33] fix: update rebased changes to match new code --- .../UpdateNickname/UpdateNicknameCommandHandler.cs | 2 +- .../Controllers/AcademicTeachersController.cs | 2 -- server/Presentation/Controllers/AuthController.cs | 2 ++ .../{UserController.cs => UsersController.cs} | 8 ++++---- ...rTests.cs => UpdateNicknameCommandHandlerTest.cs} | 12 ++++++------ .../{UserNicknameTests.cs => UserNicknameTest.cs} | 0 6 files changed, 13 insertions(+), 13 deletions(-) rename server/Presentation/Controllers/{UserController.cs => UsersController.cs} (88%) rename server/PutWiki.UnitTests/Application/{UpdateNicknameCommandHandlerTests.cs => UpdateNicknameCommandHandlerTest.cs} (94%) rename server/PutWiki.UnitTests/Domain/{UserNicknameTests.cs => UserNicknameTest.cs} (100%) diff --git a/server/Application/Features/Users/Commands/UpdateNickname/UpdateNicknameCommandHandler.cs b/server/Application/Features/Users/Commands/UpdateNickname/UpdateNicknameCommandHandler.cs index c71274e..52143e8 100644 --- a/server/Application/Features/Users/Commands/UpdateNickname/UpdateNicknameCommandHandler.cs +++ b/server/Application/Features/Users/Commands/UpdateNickname/UpdateNicknameCommandHandler.cs @@ -2,8 +2,8 @@ using System.Threading; using System.Threading.Tasks; +using Application.Abstractions; using Application.Errors; -using Application.Interfaces; using Domain.Users; diff --git a/server/Presentation/Controllers/AcademicTeachersController.cs b/server/Presentation/Controllers/AcademicTeachersController.cs index 3c5c8f3..e8670da 100644 --- a/server/Presentation/Controllers/AcademicTeachersController.cs +++ b/server/Presentation/Controllers/AcademicTeachersController.cs @@ -10,8 +10,6 @@ namespace Presentation.Controllers; -[Route("api/[controller]")] -[ApiController] public class AcademicTeachersController( GetAcademicTeacherQueryHandler getAcademicTeacherQueryHandler) : BaseApiController { diff --git a/server/Presentation/Controllers/AuthController.cs b/server/Presentation/Controllers/AuthController.cs index 8779f28..b055cc5 100644 --- a/server/Presentation/Controllers/AuthController.cs +++ b/server/Presentation/Controllers/AuthController.cs @@ -11,6 +11,7 @@ using Infrastructure.Auth.Configuration; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Hosting; @@ -67,6 +68,7 @@ CancellationToken ct return HandleResult(Result.Ok()); } + [Authorize] [HttpGet("refresh")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] diff --git a/server/Presentation/Controllers/UserController.cs b/server/Presentation/Controllers/UsersController.cs similarity index 88% rename from server/Presentation/Controllers/UserController.cs rename to server/Presentation/Controllers/UsersController.cs index 741b327..0f25e68 100644 --- a/server/Presentation/Controllers/UserController.cs +++ b/server/Presentation/Controllers/UsersController.cs @@ -4,17 +4,17 @@ using Application.Features.Users.Commands.UpdateNickname; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace Presentation.Controllers; -[Route("api/[controller]")] -[ApiController] -public class UserController( +public class UsersController( UpdateNicknameCommandHandler updateNicknameCommandHandler) : BaseApiController { - [HttpPatch("{userId:guid}")] + [Authorize] + [HttpPatch("{userId:guid}/nickname")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] diff --git a/server/PutWiki.UnitTests/Application/UpdateNicknameCommandHandlerTests.cs b/server/PutWiki.UnitTests/Application/UpdateNicknameCommandHandlerTest.cs similarity index 94% rename from server/PutWiki.UnitTests/Application/UpdateNicknameCommandHandlerTests.cs rename to server/PutWiki.UnitTests/Application/UpdateNicknameCommandHandlerTest.cs index 2828719..a13474f 100644 --- a/server/PutWiki.UnitTests/Application/UpdateNicknameCommandHandlerTests.cs +++ b/server/PutWiki.UnitTests/Application/UpdateNicknameCommandHandlerTest.cs @@ -2,29 +2,27 @@ using System.Threading; using System.Threading.Tasks; +using Application.Abstractions; using Application.Errors; using Application.Features.Users.Commands.UpdateNickname; -using Application.Interfaces; using Domain.Users; using FluentAssertions; -using FluentResults; - using Microsoft.Extensions.Logging.Abstractions; using Moq; namespace PutWiki.UnitTests.Application; -public class UpdateNicknameCommandHandlerTests +public class UpdateNicknameCommandHandlerTest { private readonly Mock _userRepositoryMock; private readonly Mock _unitOfWorkMock; private readonly UpdateNicknameCommandHandler _sut; - public UpdateNicknameCommandHandlerTests() + public UpdateNicknameCommandHandlerTest() { _userRepositoryMock = new Mock(); _unitOfWorkMock = new Mock(); @@ -98,7 +96,9 @@ public async Task ExecuteAsync_WithInvalidNickname_ShouldReturnValidationError() result.IsFailed.Should().BeTrue(); result.Errors.Should().ContainSingle().Which.Should().BeOfType(); - _userRepositoryMock.Verify(x => x.ExistsWithNicknameAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _userRepositoryMock.Verify( + x => x.ExistsWithNicknameAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); _unitOfWorkMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Never); } diff --git a/server/PutWiki.UnitTests/Domain/UserNicknameTests.cs b/server/PutWiki.UnitTests/Domain/UserNicknameTest.cs similarity index 100% rename from server/PutWiki.UnitTests/Domain/UserNicknameTests.cs rename to server/PutWiki.UnitTests/Domain/UserNicknameTest.cs From 4c43b4f8e3823cadf4428b74af74f38e27e22f33 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 12 Nov 2026 16:41:25 +0100 Subject: [PATCH 31/33] feat: add new migration --- ...efreshTokenOneToManyConstraint.Designer.cs | 192 ++++++++++++++++++ ...2841_AddRefreshTokenOneToManyConstraint.cs | 30 +++ .../Migrations/AppDbContextModelSnapshot.cs | 12 +- 3 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 server/Infrastructure/Migrations/20261112152841_AddRefreshTokenOneToManyConstraint.Designer.cs create mode 100644 server/Infrastructure/Migrations/20261112152841_AddRefreshTokenOneToManyConstraint.cs diff --git a/server/Infrastructure/Migrations/20261112152841_AddRefreshTokenOneToManyConstraint.Designer.cs b/server/Infrastructure/Migrations/20261112152841_AddRefreshTokenOneToManyConstraint.Designer.cs new file mode 100644 index 0000000..00d78b3 --- /dev/null +++ b/server/Infrastructure/Migrations/20261112152841_AddRefreshTokenOneToManyConstraint.Designer.cs @@ -0,0 +1,192 @@ +// +using System; +using Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261112152841_AddRefreshTokenOneToManyConstraint")] + partial class AddRefreshTokenOneToManyConstraint + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.AcademicTeachers.AcademicTeacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.PrimitiveCollection("Degrees") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("degrees"); + + b.Property("Description") + .HasMaxLength(2500) + .HasColumnType("character varying(2500)") + .HasColumnName("description"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("email"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PhoneNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("phone_number"); + + b.Property("PhotoUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("photo_url"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("slug"); + + b.Property("UsosId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("usos_id"); + + b.Property("WebsiteUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("website_url"); + + b.HasKey("Id") + .HasName("pk_academic_teachers"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_academic_teachers_email"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_academic_teachers_slug"); + + b.HasIndex("UsosId") + .IsUnique() + .HasDatabaseName("ix_academic_teachers_usos_id"); + + b.ToTable("academic_teachers", (string)null); + }); + + modelBuilder.Entity("Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HashedUsosId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hashed_usos_id"); + + b.Property("JoinedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_date"); + + b.Property("Nickname") + .HasMaxLength(30) + .HasColumnType("citext") + .HasColumnName("nickname"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("HashedUsosId") + .IsUnique() + .HasDatabaseName("ix_users_hashed_usos_id"); + + b.HasIndex("Nickname") + .IsUnique() + .HasDatabaseName("ix_users_nickname") + .HasFilter("\"nickname\" IS NOT NULL"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Infrastructure.Auth.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IsRevoked") + .HasColumnType("boolean") + .HasColumnName("is_revoked"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("Token") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("Infrastructure.Auth.RefreshToken", b => + { + b.HasOne("Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_users_user_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/Infrastructure/Migrations/20261112152841_AddRefreshTokenOneToManyConstraint.cs b/server/Infrastructure/Migrations/20261112152841_AddRefreshTokenOneToManyConstraint.cs new file mode 100644 index 0000000..70568a6 --- /dev/null +++ b/server/Infrastructure/Migrations/20261112152841_AddRefreshTokenOneToManyConstraint.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class AddRefreshTokenOneToManyConstraint : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddForeignKey( + name: "fk_refresh_tokens_users_user_id", + table: "refresh_tokens", + column: "user_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_refresh_tokens_users_user_id", + table: "refresh_tokens"); + } + } +} \ No newline at end of file diff --git a/server/Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/server/Infrastructure/Migrations/AppDbContextModelSnapshot.cs index b5f7502..a3d8e9d 100644 --- a/server/Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/server/Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("ProductVersion", "10.0.13") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); @@ -173,6 +173,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("refresh_tokens", (string)null); }); + + modelBuilder.Entity("Infrastructure.Auth.RefreshToken", b => + { + b.HasOne("Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_users_user_id"); + }); #pragma warning restore 612, 618 } } From 6fa8c80319488bed9c020c2cda7ef7ace9e23ae7 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 12 Nov 2026 16:47:27 +0100 Subject: [PATCH 32/33] fix: formatting --- server/Application/Extensions/ApplicationConfiguration.cs | 3 ++- server/Infrastructure/AppDbContext.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/Application/Extensions/ApplicationConfiguration.cs b/server/Application/Extensions/ApplicationConfiguration.cs index 98a3c7e..3c2ed27 100644 --- a/server/Application/Extensions/ApplicationConfiguration.cs +++ b/server/Application/Extensions/ApplicationConfiguration.cs @@ -1,8 +1,9 @@ using Application.Features.AcademicTeachers.Queries; using Application.Features.Users.Commands.ProvisionUser; -using Application.Features.Users.Commands.UpdateNickname; using Application.Features.Users.Commands.RefreshUserSession; +using Application.Features.Users.Commands.UpdateNickname; using Application.Mappings; + using Microsoft.Extensions.DependencyInjection; namespace Application.Extensions; diff --git a/server/Infrastructure/AppDbContext.cs b/server/Infrastructure/AppDbContext.cs index 8ff17ae..b065e80 100644 --- a/server/Infrastructure/AppDbContext.cs +++ b/server/Infrastructure/AppDbContext.cs @@ -19,7 +19,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.HasPostgresExtension("citext"); - + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); } } \ No newline at end of file From a8477ea4629c00566090a191d73e9660d00368c2 Mon Sep 17 00:00:00 2001 From: Aleksander Nowicki Date: Thu, 12 Nov 2026 17:02:25 +0100 Subject: [PATCH 33/33] fix(AuthController): use POST for /refresh --- server/Presentation/Controllers/AuthController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Presentation/Controllers/AuthController.cs b/server/Presentation/Controllers/AuthController.cs index b055cc5..75a4909 100644 --- a/server/Presentation/Controllers/AuthController.cs +++ b/server/Presentation/Controllers/AuthController.cs @@ -69,7 +69,7 @@ CancellationToken ct } [Authorize] - [HttpGet("refresh")] + [HttpPost("refresh")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] public async Task Refresh(CancellationToken ct)