diff --git a/server/.editorconfig b/server/.editorconfig index 04549e64..2104d837 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/.env.example b/server/.env.example index f228e673..1caec94d 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/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 466ea957..2a77901b 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 00000000..47b2a2d8 --- /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/Auth/IUserSessionService.cs b/server/Application/Auth/IUserSessionService.cs new file mode 100644 index 00000000..9b3887bd --- /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 rawRefreshTokenString); +} \ No newline at end of file diff --git a/server/Application/Auth/JwtTokenPair.cs b/server/Application/Auth/JwtTokenPair.cs new file mode 100644 index 00000000..befd4b50 --- /dev/null +++ b/server/Application/Auth/JwtTokenPair.cs @@ -0,0 +1,3 @@ +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 6756be9c..3c2ed27b 100644 --- a/server/Application/Extensions/ApplicationConfiguration.cs +++ b/server/Application/Extensions/ApplicationConfiguration.cs @@ -1,5 +1,6 @@ -using Application.Features.AcademicTeachers.Queries; +using Application.Features.AcademicTeachers.Queries; using Application.Features.Users.Commands.ProvisionUser; +using Application.Features.Users.Commands.RefreshUserSession; using Application.Features.Users.Commands.UpdateNickname; using Application.Mappings; @@ -17,6 +18,8 @@ public static IServiceCollection AddApplication(this IServiceCollection services }); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); 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 bb054348..ab63d9d0 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 471b9447..118f24df 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 b2ecc1f0..c3fc85ca 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/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs b/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs index 8d504444..1d3288f3 100644 --- a/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs +++ b/server/Application/Features/Users/Commands/ProvisionUser/ProvisionUserCommandHandler.cs @@ -2,11 +2,9 @@ using System.Threading; using System.Threading.Tasks; +using Application.Abstractions; using Application.Auth; using Application.Errors; -using Application.Interfaces; - -using AutoMapper; using Domain.Users; @@ -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 tokens = userSessionService.CreateSession(user.Id); - LogProvisionedNewAnonymousUserId(newUser.Id); + await unitOfWork.SaveChangesAsync(ct); - return Result.Ok(mapper.Map(newUser)); + return Result.Ok(new JwtTokenPair(tokens.AccessToken, tokens.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 0e98e57b..00000000 --- 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/Features/Users/Commands/RefreshUserSession/RefreshSessionCommand.cs b/server/Application/Features/Users/Commands/RefreshUserSession/RefreshSessionCommand.cs new file mode 100644 index 00000000..73c7a16b --- /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 00000000..03c5354c --- /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/Features/Users/Commands/UpdateNickname/UpdateNicknameCommandHandler.cs b/server/Application/Features/Users/Commands/UpdateNickname/UpdateNicknameCommandHandler.cs index c71274e8..52143e88 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/Application/Mappings/MappingsProfile.cs b/server/Application/Mappings/MappingsProfile.cs index afa2f201..5f9d0efb 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 b679defe..22ef1972 100644 --- a/server/Application/packages.lock.json +++ b/server/Application/packages.lock.json @@ -24,11 +24,11 @@ }, "FluentValidation.DependencyInjectionExtensions": { "type": "Direct", - "requested": "[12.1.1, )", - "resolved": "12.1.1", - "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", + "requested": "[12.2.0, )", + "resolved": "12.2.0", + "contentHash": "QpKXgiO9E6wXCh19X2Musn8MZM0xt7KQt/QrD6GDBn3MCUfy8a+rkhCzrYJNCfoxTsTRQ7bpKP8T3geTnJNNeQ==", "dependencies": { - "FluentValidation": "12.1.1", + "FluentValidation": "12.2.0", "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" } }, @@ -57,8 +57,8 @@ }, "FluentValidation": { "type": "Transitive", - "resolved": "12.1.1", - "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" + "resolved": "12.2.0", + "contentHash": "DR3lFbdnKucaaj1/8zMNOACMz/64igzox1yVZSFVsLxYB5mxAB+wFP1WZ8fjwrZnbU1KA1DoMzHKYKxUfuZ4wQ==" }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", diff --git a/server/Directory.Packages.props b/server/Directory.Packages.props index a7702d11..65b78f80 100644 --- a/server/Directory.Packages.props +++ b/server/Directory.Packages.props @@ -10,15 +10,18 @@ - + + + - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/server/Infrastructure/AppDbContext.cs b/server/Infrastructure/AppDbContext.cs index 9853fc1c..b065e809 100644 --- a/server/Infrastructure/AppDbContext.cs +++ b/server/Infrastructure/AppDbContext.cs @@ -1,9 +1,9 @@ -using Application.Interfaces; +using Application.Abstractions; using Domain.AcademicTeachers; using Domain.Users; -using Infrastructure.EntitiesConfiguration; +using Infrastructure.Auth; using Microsoft.EntityFrameworkCore; @@ -11,16 +11,15 @@ namespace Infrastructure; public class AppDbContext(DbContextOptions options) : DbContext(options), IUnitOfWork { - public required DbSet Users { get; set; } - public required DbSet AcademicTeachers { get; set; } + public DbSet Users => Set(); + public DbSet AcademicTeachers => Set(); + public virtual DbSet RefreshTokens => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - modelBuilder.HasPostgresExtension("citext"); - new UserEntityTypeConfiguration().Configure(modelBuilder.Entity()); - new AcademicTeacherEntityTypeConfiguration().Configure(modelBuilder.Entity()); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); } } \ No newline at end of file diff --git a/server/Infrastructure/Auth/Configuration/JwtSettings.cs b/server/Infrastructure/Auth/Configuration/JwtSettings.cs new file mode 100644 index 00000000..aafdac0a --- /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/JwtTokensSettingsValidator.cs b/server/Infrastructure/Auth/Configuration/JwtTokensSettingsValidator.cs new file mode 100644 index 00000000..71b51a5c --- /dev/null +++ b/server/Infrastructure/Auth/Configuration/JwtTokensSettingsValidator.cs @@ -0,0 +1,26 @@ +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."); + + 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/UsosOAuthSettings.cs b/server/Infrastructure/Auth/Configuration/UsosOAuthSettings.cs index 83fcd21e..38e7743c 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/Configuration/UsosOAuthSettingsValidator.cs b/server/Infrastructure/Auth/Configuration/UsosOAuthSettingsValidator.cs index 04627589..eb12df35 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 diff --git a/server/Infrastructure/Auth/JwtUserSessionService.cs b/server/Infrastructure/Auth/JwtUserSessionService.cs new file mode 100644 index 00000000..c81b6d85 --- /dev/null +++ b/server/Infrastructure/Auth/JwtUserSessionService.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +using Application.Auth; + +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 partial class JwtUserSessionService( + IOptions settings, + AppDbContext dbContext, + TimeProvider timeProvider, + ILogger logger + ) : IUserSessionService +{ + /** + * NOTE: after calling this method use IUnitOfWork to persist changes + */ + 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 = hashedRefreshTokenString, + ExpiresAt = timeProvider.GetUtcNow().Add(settings.Value.RefreshTokenLifetime), + CreatedAt = timeProvider.GetUtcNow() + }; + + dbContext.RefreshTokens.Add(refreshToken); + + return new JwtTokenPair(accessToken, rawRefreshTokenString); + } + + /** + * NOTE: after calling this method use IUnitOfWork to persist changes + */ + public async Task RefreshSession(string rawRefreshTokenString) + { + var hashedRefreshTokenString = RefreshTokenHasher.CreateHash(rawRefreshTokenString); + + var storedTokenObject = await dbContext.RefreshTokens + .FirstOrDefaultAsync(r => r.Token == hashedRefreshTokenString); + + 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; + } + + var rowsAffected = await dbContext.RefreshTokens + .Where(r => r.Token == hashedRefreshTokenString && !r.IsRevoked) + .ExecuteUpdateAsync(s => s + .SetProperty(r => r.IsRevoked, true)); + if (rowsAffected == 0) + { + return null; + } + + return CreateSession(storedTokenObject.UserId); + } + + private string GenerateAccessToken(Guid userId) + { + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + }; + + var key = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(settings.Value.SecretKey)); + + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = timeProvider.GetUtcNow().Add(settings.Value.AccessTokenLifetime).UtcDateTime, + SigningCredentials = credentials, + Issuer = settings.Value.Issuer, + Audience = settings.Value.Audience + }; + + 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 diff --git a/server/Infrastructure/Auth/RefreshToken.cs b/server/Infrastructure/Auth/RefreshToken.cs new file mode 100644 index 00000000..445fafac --- /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 DateTimeOffset ExpiresAt { get; set; } + public bool IsRevoked { get; set; } + public DateTimeOffset CreatedAt { get; set; } +} \ No newline at end of file diff --git a/server/Infrastructure/Auth/RefreshTokenHasher.cs b/server/Infrastructure/Auth/RefreshTokenHasher.cs new file mode 100644 index 00000000..99738a96 --- /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 diff --git a/server/Infrastructure/Auth/UserContext.cs b/server/Infrastructure/Auth/UserContext.cs new file mode 100644 index 00000000..00af57ed --- /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/EntitiesConfiguration/RefreshTokenEntityTypeConfiguration.cs b/server/Infrastructure/EntitiesConfiguration/RefreshTokenEntityTypeConfiguration.cs new file mode 100644 index 00000000..89d61ed5 --- /dev/null +++ b/server/Infrastructure/EntitiesConfiguration/RefreshTokenEntityTypeConfiguration.cs @@ -0,0 +1,27 @@ +using Domain.Users; + +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); + + builder.HasOne() + .WithMany() + .HasForeignKey(x => x.UserId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/server/Infrastructure/Extensions/InfrastructureConfiguration.cs b/server/Infrastructure/Extensions/InfrastructureConfiguration.cs index cc374e06..4fc2a73e 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; @@ -21,6 +21,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Infrastructure.Extensions; @@ -46,21 +47,48 @@ 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(JwtSettings.SectionName)) + .Validate(settings => + { + var validator = new JwtTokensSettingsValidator(); + var validationResult = validator.Validate(settings); + if (!validationResult.IsValid) + { + throw new OptionsValidationException( + "JwtSettings", + typeof(JwtSettings), + validationResult.Errors.Select(e => e.ErrorMessage) + ); + } + return true; + }) + .ValidateOnStart(); + + services.AddScoped(); + + services.AddHttpContextAccessor(); + services.AddScoped(); + } + 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(); 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) diff --git a/server/Infrastructure/Infrastructure.csproj b/server/Infrastructure/Infrastructure.csproj index 7364f2bb..c46dc914 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/Migrations/20261022205450_AddRefreshTokenEntity.Designer.cs b/server/Infrastructure/Migrations/20261022205450_AddRefreshTokenEntity.Designer.cs new file mode 100644 index 00000000..de20ecfe --- /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 00000000..aacf54cc --- /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/20261112152841_AddRefreshTokenOneToManyConstraint.Designer.cs b/server/Infrastructure/Migrations/20261112152841_AddRefreshTokenOneToManyConstraint.Designer.cs new file mode 100644 index 00000000..00d78b3a --- /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 00000000..70568a65 --- /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 3facbb6b..a3d8e9d6 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"); @@ -132,6 +132,57 @@ 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); + }); + + 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/Services/AcademicTeacherQueryService.cs b/server/Infrastructure/Services/AcademicTeacherQueryService.cs index 8542fd47..245a5f99 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/Infrastructure/packages.lock.json b/server/Infrastructure/packages.lock.json index f86777b3..8b5bd278 100644 --- a/server/Infrastructure/packages.lock.json +++ b/server/Infrastructure/packages.lock.json @@ -18,19 +18,28 @@ "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==", + "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" } @@ -59,14 +68,19 @@ }, "FluentValidation": { "type": "Transitive", - "resolved": "12.1.1", - "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" + "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", @@ -134,46 +148,64 @@ }, "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", - "resolved": "8.14.0", - "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + "resolved": "8.19.2", + "contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw==" }, "Microsoft.IdentityModel.JsonWebTokens": { "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "4jOpiA4THdtpLyMdAb24dtj7+6GmvhOhxf5XHLYWmPKF8ApEnApal1UnJsKO4HxUWRXDA6C4WQVfYyqsRhpNpQ==", + "resolved": "8.19.2", + "contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==", "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.14.0" + "Microsoft.IdentityModel.Tokens": "8.19.2" } }, "Microsoft.IdentityModel.Logging": { "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==", + "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.Abstractions": "8.14.0" + "Microsoft.IdentityModel.Protocols": "8.19.2", + "System.IdentityModel.Tokens.Jwt": "8.19.2" } }, "Microsoft.IdentityModel.Tokens": { "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "lKIZiBiGd36k02TCdMHp1KlNWisyIvQxcYJvIkz7P4gSQ9zi8dgh6S5Grj8NNG7HWYIPfQymGyoZ6JB5d1Lo1g==", + "resolved": "8.19.2", + "contentHash": "GtPC1S02uH1gOO4fQ+zRysIicKmEXaYFP8PIkdJYXqMyruYhopre4ozVHp0XiDSA0+GJvOZH9prxCPvgBMg4Ww==", "dependencies": { - "Microsoft.IdentityModel.Logging": "8.14.0" + "Microsoft.Bcl.Cryptography": "10.0.2", + "Microsoft.IdentityModel.Logging": "8.19.2" } }, "Microsoft.VisualStudio.SolutionPersistence": { @@ -247,13 +279,22 @@ "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.1.1, )", + "FluentValidation.DependencyInjectionExtensions": "[12.2.0, )", "Moq": "[4.21.0, )" } }, @@ -274,30 +315,30 @@ }, "FluentValidation.DependencyInjectionExtensions": { "type": "CentralTransitive", - "requested": "[12.1.1, )", - "resolved": "12.1.1", - "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", + "requested": "[12.2.0, )", + "resolved": "12.2.0", + "contentHash": "QpKXgiO9E6wXCh19X2Musn8MZM0xt7KQt/QrD6GDBn3MCUfy8a+rkhCzrYJNCfoxTsTRQ7bpKP8T3geTnJNNeQ==", "dependencies": { - "FluentValidation": "12.1.1" + "FluentValidation": "12.2.0" } }, "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/Controllers/AcademicTeachersController.cs b/server/Presentation/Controllers/AcademicTeachersController.cs index bb76d282..e8670da3 100644 --- a/server/Presentation/Controllers/AcademicTeachersController.cs +++ b/server/Presentation/Controllers/AcademicTeachersController.cs @@ -10,13 +10,11 @@ namespace Presentation.Controllers; -[Route("api/[controller]")] -[ApiController] 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) { diff --git a/server/Presentation/Controllers/AuthController.cs b/server/Presentation/Controllers/AuthController.cs index a4eccd88..75a4909c 100644 --- a/server/Presentation/Controllers/AuthController.cs +++ b/server/Presentation/Controllers/AuthController.cs @@ -1,17 +1,34 @@ +using System; using System.Threading; using System.Threading.Tasks; using Application.Auth; +using Application.Errors; using Application.Features.Users.Commands.ProvisionUser; +using Application.Features.Users.Commands.RefreshUserSession; +using FluentResults; + +using Infrastructure.Auth.Configuration; + +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +using Presentation.Extensions; + +using Scalar.AspNetCore; namespace Presentation.Controllers; public class AuthController( IUsosOAuthService usosOAuthService, - ProvisionUserCommandHandler provisionUserCommandHandler) : BaseApiController + ProvisionUserCommandHandler provisionUserCommandHandler, + RefreshSessionCommandHandler refreshSessionCommandHandler, + IOptions jwtSettings, + IHostEnvironment environment) : BaseApiController { [HttpGet("login")] @@ -27,7 +44,8 @@ public async Task Login(CancellationToken ct) } [HttpGet("callback")] - [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] + [ExcludeFromApiReference] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status502BadGateway)] @@ -40,6 +58,63 @@ CancellationToken ct var command = new ProvisionUserCommand(oauthToken, oauthVerifier); var result = await provisionUserCommandHandler.ExecuteAsync(command, ct); - return HandleResult(result); + if (result.IsFailed) + { + return HandleResult(result); + } + + AppendTokenCookies(result.Value); + + return HandleResult(Result.Ok()); + } + + [Authorize] + [HttpPost("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, + 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(AuthConstants.AccessTokenCookieName, tokenPair.AccessToken, accessTokenOptions); + Response.Cookies.Append(AuthConstants.RefreshTokenCookieName, tokenPair.RefreshToken, refreshTokenOptions); } } \ No newline at end of file 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 741b327f..0f25e68f 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/Presentation/Extensions/AuthConstants.cs b/server/Presentation/Extensions/AuthConstants.cs new file mode 100644 index 00000000..2abe23fb --- /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 47ada56f..3c69038d 100644 --- a/server/Presentation/Extensions/PresentationConfiguration.cs +++ b/server/Presentation/Extensions/PresentationConfiguration.cs @@ -1,11 +1,18 @@ -using System.Text.Json; +using System; +using System.Text; +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.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi; using Presentation.Transformers; @@ -63,6 +70,51 @@ private static void AddPutWikiOpenApi(this IServiceCollection services) }); } + private static void AddPutWikiAuthentication(this IServiceCollection services) + { + services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure>((options, jwtOptions) => + { + var settings = jwtOptions.Value; + + options.MapInboundClaims = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = settings.Issuer, + + ValidateAudience = true, + ValidAudience = settings.Audience, + + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(settings.SecretKey)), + + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }; + + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + if (context.Request.Cookies.TryGetValue(AuthConstants.AccessTokenCookieName, out var accessToken)) + { + context.Token = accessToken; + } + + return Task.CompletedTask; + } + }; + }); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); + + services.AddAuthorization(); + } + public static IApplicationBuilder UsePutWikiOpenApiDocs(this WebApplication app) { if (app.Environment.IsDevelopment()) @@ -111,7 +163,10 @@ public static IServiceCollection AddPresentation(this IServiceCollection service }); services.AddPutWikiOpenApi(); + services.AddPutWikiAuthentication(); return services; } + + } \ No newline at end of file diff --git a/server/Presentation/Presentation.csproj b/server/Presentation/Presentation.csproj index 77ad7b4a..d5b33916 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 0a6b1b30..f71e9812 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.AddApplication(); builder.Services.AddPresentation(); +builder.Services.AddApplication(); var app = builder.Build(); diff --git a/server/Presentation/appsettings.json b/server/Presentation/appsettings.json index 495eae77..b00cabd4 100644 --- a/server/Presentation/appsettings.json +++ b/server/Presentation/appsettings.json @@ -18,5 +18,11 @@ "UsosOAuth": { "BaseUrl": "https://usosapps.put.poznan.pl/", "Scopes": "studies" + }, + "Jwt": { + "Issuer": "putwiki-api", + "Audience": "putwiki-frontend", + "AccessTokenLifetime": "00:30:00", + "RefreshTokenLifetime": "7.00:00:00" } } diff --git a/server/Presentation/packages.lock.json b/server/Presentation/packages.lock.json index 9af254c6..f8996a8f 100644 --- a/server/Presentation/packages.lock.json +++ b/server/Presentation/packages.lock.json @@ -17,6 +17,15 @@ "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, )", @@ -28,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" } @@ -73,14 +82,19 @@ }, "FluentValidation": { "type": "Transitive", - "resolved": "12.1.1", - "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" + "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", @@ -148,46 +162,64 @@ }, "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", - "resolved": "8.14.0", - "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + "resolved": "8.19.2", + "contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw==" }, "Microsoft.IdentityModel.JsonWebTokens": { "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "4jOpiA4THdtpLyMdAb24dtj7+6GmvhOhxf5XHLYWmPKF8ApEnApal1UnJsKO4HxUWRXDA6C4WQVfYyqsRhpNpQ==", + "resolved": "8.19.2", + "contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==", "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.14.0" + "Microsoft.IdentityModel.Tokens": "8.19.2" } }, "Microsoft.IdentityModel.Logging": { "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==", + "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.Abstractions": "8.14.0" + "Microsoft.IdentityModel.Protocols": "8.19.2", + "System.IdentityModel.Tokens.Jwt": "8.19.2" } }, "Microsoft.IdentityModel.Tokens": { "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "lKIZiBiGd36k02TCdMHp1KlNWisyIvQxcYJvIkz7P4gSQ9zi8dgh6S5Grj8NNG7HWYIPfQymGyoZ6JB5d1Lo1g==", + "resolved": "8.19.2", + "contentHash": "GtPC1S02uH1gOO4fQ+zRysIicKmEXaYFP8PIkdJYXqMyruYhopre4ozVHp0XiDSA0+GJvOZH9prxCPvgBMg4Ww==", "dependencies": { - "Microsoft.IdentityModel.Logging": "8.14.0" + "Microsoft.Bcl.Cryptography": "10.0.2", + "Microsoft.IdentityModel.Logging": "8.19.2" } }, "Microsoft.OpenApi": { @@ -271,13 +303,22 @@ "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.1.1, )", + "FluentValidation.DependencyInjectionExtensions": "[12.2.0, )", "Moq": "[4.21.0, )" } }, @@ -294,6 +335,7 @@ "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, )" } }, @@ -318,30 +360,30 @@ }, "FluentValidation.DependencyInjectionExtensions": { "type": "CentralTransitive", - "requested": "[12.1.1, )", - "resolved": "12.1.1", - "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", + "requested": "[12.2.0, )", + "resolved": "12.2.0", + "contentHash": "QpKXgiO9E6wXCh19X2Musn8MZM0xt7KQt/QrD6GDBn3MCUfy8a+rkhCzrYJNCfoxTsTRQ7bpKP8T3geTnJNNeQ==", "dependencies": { - "FluentValidation": "12.1.1" + "FluentValidation": "12.2.0" } }, "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/Application/ProvisionUserCommandHandlerTests.cs b/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTest.cs similarity index 77% rename from server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTests.cs rename to server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTest.cs index 2f11db47..1671fc8e 100644 --- a/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTests.cs +++ b/server/PutWiki.UnitTests/Application/ProvisionUserCommandHandlerTest.cs @@ -2,13 +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 Application.Mappings; - -using AutoMapper; using Domain.Users; @@ -23,28 +20,24 @@ namespace PutWiki.UnitTests.Application; -public class ProvisionUserCommandHandlerTests +public class ProvisionUserCommandHandlerTest { private readonly Mock _usosOAuthServiceMock; private readonly Mock _idHasherMock; private readonly Mock _userRepositoryMock; private readonly Mock _iUnitOfWorkMock; private readonly FakeTimeProvider _fakeTimeProvider; + private readonly Mock _userSessionServiceMock; private readonly ProvisionUserCommandHandler _sut; - public ProvisionUserCommandHandlerTests() + public ProvisionUserCommandHandlerTest() { _usosOAuthServiceMock = new Mock(); _idHasherMock = new Mock(); _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 +45,8 @@ public ProvisionUserCommandHandlerTests() _userRepositoryMock.Object, _iUnitOfWorkMock.Object, NullLogger.Instance, - mapper, - _fakeTimeProvider + _fakeTimeProvider, + _userSessionServiceMock.Object ); } @@ -77,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"); @@ -89,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())) @@ -102,20 +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.Id.Should().Be(existingUser.Id.ToString()); - result.Value.HashedUsosId.Should().Be(hashedUsosId); + 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"); @@ -123,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)); @@ -134,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 @@ -142,9 +148,11 @@ 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); + 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); 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 28287193..a13474ff 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/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 e78524ca..937012d8 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 9f283055..fc90ca68 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/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 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 b050d2ee..05e2203a 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/JwtUserSessionServiceTest.cs b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs new file mode 100644 index 00000000..bf36537a --- /dev/null +++ b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTest.cs @@ -0,0 +1,321 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +using FluentAssertions; + +using Infrastructure; +using Infrastructure.Auth; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Time.Testing; +using Microsoft.IdentityModel.JsonWebTokens; + +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 = "valid-issuer"; + private const string ValidAudience = "valid-audience"; + + private readonly JwtUserSessionServiceTestFixture _fixture = new(); + private readonly FakeTimeProvider _fakeTimeProvider = new(); + + public void Dispose() + { + _fixture.Dispose(); + } + + private (JwtUserSessionService Sut, AppDbContext Context) CreateSut( + string secretKey = ValidSecretKey, + string issuer = ValidIssuer, + string audience = ValidAudience, + TimeSpan? accessTokenLifetime = null, + TimeSpan? refreshTokenLifetime = null) + { + return _fixture.CreateSut(_fakeTimeProvider, secretKey, issuer, audience, accessTokenLifetime, refreshTokenLifetime); + } + + [Fact] + public async Task CreateSession_GivenValidUserId_ReturnsValidJwtAndRefreshTokenPair() + { + // Arrange + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var (sut, context) = CreateSut(); + + // Act + var result = sut.CreateSession(user.Id); + await context.SaveChangesAsync(); + + // 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(user.Id.ToString()); + } + + [Fact] + public async Task CreateSession_GivenValidUserId_PersistsRefreshToken() + { + // Arrange + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var (sut, context) = CreateSut(); + + // Act + var result = sut.CreateSession(user.Id); + await context.SaveChangesAsync(); + + // Assert + var hashedRefreshToken = RefreshTokenHasher.CreateHash(result.RefreshToken); + var refreshToken = await context.RefreshTokens.SingleAsync(rt => + rt.UserId == user.Id && + rt.Token == hashedRefreshToken && + rt.Id != Guid.Empty); + + refreshToken.Should().NotBeNull(); + } + + [Fact] + public async Task CreateSession_GivenConfiguredLifetimes_SetsCorrectAccessTokenExpirationTime() + { + // Arrange + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var accessTokenLifetime = TimeSpan.FromMinutes(45); + var fixedTime = new DateTimeOffset(2026, 10, 22, 23, 0, 0, TimeSpan.Zero); + _fakeTimeProvider.SetUtcNow(fixedTime); + + var (sut, context) = CreateSut(accessTokenLifetime: accessTokenLifetime); + + // Act + var result = sut.CreateSession(user.Id); + await context.SaveChangesAsync(); + + // 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 async Task CreateSession_GivenConfiguredLifetimes_SetsCorrectRefreshTokenExpirationAndCreationDates() + { + // Arrange + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var refreshTokenLifetime = TimeSpan.FromDays(14); + var fixedTime = new DateTimeOffset(2026, 10, 22, 23, 0, 0, TimeSpan.Zero); + _fakeTimeProvider.SetUtcNow(fixedTime); + + var (sut, context) = CreateSut(refreshTokenLifetime: refreshTokenLifetime); + + // Act + sut.CreateSession(user.Id); + await context.SaveChangesAsync(); + + // Assert + var savedRefreshToken = await context.RefreshTokens.SingleAsync(rt => rt.UserId == user.Id); + savedRefreshToken.CreatedAt.Should().Be(fixedTime); + savedRefreshToken.ExpiresAt.Should().Be(fixedTime.Add(refreshTokenLifetime)); + } + + [Fact] + public async Task CreateSession_WhenCalledMultipleTimesForSameUser_GeneratesUniqueRefreshTokens() + { + // Arrange + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var (sut, context) = CreateSut(); + + // Act + var session1 = sut.CreateSession(user.Id); + var session2 = sut.CreateSession(user.Id); + await context.SaveChangesAsync(); + + // Assert + session1.RefreshToken.Should().NotBe(session2.RefreshToken); + } + + [Fact] + public async Task CreateSession_WhenCalledMultipleTimesForSameUser_StoresAllRefreshTokensNotOverwritesThem() + { + // Arrange + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var (sut, context) = CreateSut(); + + // Act + sut.CreateSession(user.Id); + sut.CreateSession(user.Id); + await context.SaveChangesAsync(); + + // Assert + 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); + } + + [Fact] + public async Task CreateSession_WhenConfiguredWithIssuerAndAudience_EmbedsClaimsInJwt() + { + // Arrange + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var expectedIssuer = "custom issuer"; + var expectedAudience = "custom audience"; + var (sut, context) = CreateSut(issuer: expectedIssuer, audience: expectedAudience); + + // Act + var result = sut.CreateSession(user.Id); + await context.SaveChangesAsync(); + + // Assert + var handler = new JsonWebTokenHandler(); + var jwtToken = handler.ReadJsonWebToken(result.AccessToken); + + jwtToken.Issuer.Should().Be(expectedIssuer); + jwtToken.Audiences.Should().Contain(expectedAudience); + } + + [Fact] + public async Task RefreshSession_GivenValidRefreshToken_ShouldRevokeIt() + { + // Arrange + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var rawRefreshTokenString = "raw-refresh-token"; + var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); + _fakeTimeProvider.SetUtcNow(fixedTime); + + var (sut, context) = CreateSut(); + + await _fixture.CreateRefreshTokenAsync( + userId: user.Id, + timeProvider: _fakeTimeProvider, + rawTokenString: rawRefreshTokenString + ); + + // Act + await sut.RefreshSession(rawRefreshTokenString); + await context.SaveChangesAsync(); + + // Assert + 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] + public async Task RefreshSession_GivenValidRefreshToken_ShouldReturnNewTokenPair() + { + // Arrange + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var rawRefreshTokenString = "valid-refresh-token"; + var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); + _fakeTimeProvider.SetUtcNow(fixedTime); + + var (sut, context) = CreateSut(); + + await _fixture.CreateRefreshTokenAsync( + userId: user.Id, + timeProvider: _fakeTimeProvider, + rawTokenString: rawRefreshTokenString + ); + + // Act + 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(rawRefreshTokenString); + } + + [Fact] + public async Task RefreshSession_GivenInvalidRefreshTokenString_ShouldReturnNull() + { + // Arrange + var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); + _fakeTimeProvider.SetUtcNow(fixedTime); + + var (sut, context) = CreateSut(); + + // Act + var result = await sut.RefreshSession("non-existent-token"); + await context.SaveChangesAsync(); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task RefreshSession_GivenRevokedRefreshToken_ShouldRevokeAllUserTokensAndReturnNull() + { + // Arrange + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + 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(); + + 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(revokedRawRefreshTokenString); + await context.SaveChangesAsync(); + + // Assert + 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(); + } + + [Fact] + public async Task RefreshSession_GivenExpiredRefreshToken_ShouldReturnNull() + { + // Arrange + var user = await _fixture.CreateUserAsync(_fakeTimeProvider); + var rawRefreshTokenString = "expired-refresh-token"; + var fixedTime = new DateTimeOffset(2026, 10, 24, 19, 0, 0, TimeSpan.Zero); + _fakeTimeProvider.SetUtcNow(fixedTime); + + var (sut, context) = CreateSut(); + + await _fixture.CreateRefreshTokenAsync( + userId: user.Id, + timeProvider: _fakeTimeProvider, + rawTokenString: rawRefreshTokenString, + age: TimeSpan.FromDays(5), + lifetime: TimeSpan.FromDays(2) + ); + + // Act + var result = await sut.RefreshSession(rawRefreshTokenString); + await context.SaveChangesAsync(); + + // Assert + result.Should().BeNull(); + } +} \ No newline at end of file diff --git a/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTestFixture.cs b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTestFixture.cs new file mode 100644 index 00000000..c563c632 --- /dev/null +++ b/server/PutWiki.UnitTests/Infrastructure/Auth/JwtUserSessionServiceTestFixture.cs @@ -0,0 +1,121 @@ +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 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(); + } +} \ No newline at end of file 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 f6c9ed54..4e632419 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")] diff --git a/server/PutWiki.UnitTests/PutWiki.UnitTests.csproj b/server/PutWiki.UnitTests/PutWiki.UnitTests.csproj index bc8b2131..fa50b1e0 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 f8cfb8ab..01b55a42 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, )", @@ -72,125 +87,175 @@ }, "FluentValidation": { "type": "Transitive", - "resolved": "12.1.1", - "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" + "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.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", - "resolved": "8.14.0", - "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + "resolved": "8.19.2", + "contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw==" }, "Microsoft.IdentityModel.JsonWebTokens": { "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "4jOpiA4THdtpLyMdAb24dtj7+6GmvhOhxf5XHLYWmPKF8ApEnApal1UnJsKO4HxUWRXDA6C4WQVfYyqsRhpNpQ==", + "resolved": "8.19.2", + "contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==", "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.14.0" + "Microsoft.IdentityModel.Tokens": "8.19.2" } }, "Microsoft.IdentityModel.Logging": { "type": "Transitive", - "resolved": "8.14.0", - "contentHash": "eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==", + "resolved": "8.19.2", + "contentHash": "r5YLDIxGOnkVJHrqXv/iD1FM1CgGrQOdriXuvuWvTPmKbnGANhEysq9XKmN6IHjf2a+9bAGEpnoRBsAQlvJU5w==", "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.14.0" + "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.14.0", - "contentHash": "lKIZiBiGd36k02TCdMHp1KlNWisyIvQxcYJvIkz7P4gSQ9zi8dgh6S5Grj8NNG7HWYIPfQymGyoZ6JB5d1Lo1g==", + "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.14.0" + "Microsoft.IdentityModel.Logging": "8.19.2" } }, "Microsoft.TestPlatform.ObjectModel": { @@ -206,11 +271,47 @@ "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", "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", @@ -257,7 +358,7 @@ "AutoMapper": "[16.3.0, )", "Domain": "[1.0.0, )", "FluentResults": "[4.0.0, )", - "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", + "FluentValidation.DependencyInjectionExtensions": "[12.2.0, )", "Moq": "[4.21.0, )" } }, @@ -274,6 +375,7 @@ "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, )" } }, @@ -301,36 +403,45 @@ }, "FluentValidation.DependencyInjectionExtensions": { "type": "CentralTransitive", - "requested": "[12.1.1, )", - "resolved": "12.1.1", - "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", + "requested": "[12.2.0, )", + "resolved": "12.2.0", + "contentHash": "QpKXgiO9E6wXCh19X2Musn8MZM0xt7KQt/QrD6GDBn3MCUfy8a+rkhCzrYJNCfoxTsTRQ7bpKP8T3geTnJNNeQ==", "dependencies": { - "FluentValidation": "12.1.1", + "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==", + "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": { diff --git a/server/README.md b/server/README.md index 078ef5f6..e180a864 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. diff --git a/server/dotnet-tools.json b/server/dotnet-tools.json index 5c1165bb..39007e02 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" ],