From 6b0a97e617b62ae61b8137cc7240105fe5aff673 Mon Sep 17 00:00:00 2001 From: Veljko Tosic Date: Sat, 14 Feb 2026 18:48:39 +0100 Subject: [PATCH] Added refresh tokens and reorganized file structure --- .../{ => AuthService}/EfAuthService.cs | 32 +++++++++- .../Authentication/{ => Jwt}/JwtSettings.cs | 3 +- .../{ => Jwt}/JwtTokenProvider.cs | 10 ++- .../RefreshToken/RefreshToken.cs | 19 ++++++ .../RefreshToken/RefreshTokenException.cs | 16 +++++ .../RefreshToken/RefreshTokenMappers.cs | 12 ++++ .../RefreshToken/RefreshTokenRepository.cs | 64 +++++++++++++++++++ .../{ => UserContext}/HttpUserContext.cs | 2 +- 8 files changed, 152 insertions(+), 6 deletions(-) rename dotnet/AipsCore/Infrastructure/Persistence/Authentication/{ => AuthService}/EfAuthService.cs (63%) rename dotnet/AipsCore/Infrastructure/Persistence/Authentication/{ => Jwt}/JwtSettings.cs (65%) rename dotnet/AipsCore/Infrastructure/Persistence/Authentication/{ => Jwt}/JwtTokenProvider.cs (80%) create mode 100644 dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshToken.cs create mode 100644 dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshTokenException.cs create mode 100644 dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshTokenMappers.cs create mode 100644 dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshTokenRepository.cs rename dotnet/AipsCore/Infrastructure/Persistence/Authentication/{ => UserContext}/HttpUserContext.cs (92%) diff --git a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/EfAuthService.cs b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/AuthService/EfAuthService.cs similarity index 63% rename from dotnet/AipsCore/Infrastructure/Persistence/Authentication/EfAuthService.cs rename to dotnet/AipsCore/Infrastructure/Persistence/Authentication/AuthService/EfAuthService.cs index 700bf42..996be94 100644 --- a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/EfAuthService.cs +++ b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/AuthService/EfAuthService.cs @@ -2,17 +2,21 @@ using AipsCore.Application.Common.Authentication; using AipsCore.Domain.Common.Validation; using AipsCore.Domain.Models.User.External; using AipsCore.Domain.Models.User.Validation; +using AipsCore.Infrastructure.Persistence.Db; using AipsCore.Infrastructure.Persistence.User.Mappers; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; -namespace AipsCore.Infrastructure.Persistence.Authentication; +namespace AipsCore.Infrastructure.Persistence.Authentication.AuthService; public class EfAuthService : IAuthService { + private readonly AipsDbContext _dbContext; private readonly UserManager _userManager; - public EfAuthService(UserManager userManager) + public EfAuthService(AipsDbContext dbContext, UserManager userManager) { + _dbContext = dbContext; _userManager = userManager; } @@ -61,4 +65,28 @@ public class EfAuthService : IAuthService return new LoginResult(entity.MapToModel(), roles); } + + public async Task LoginWithRefreshTokenAsync(Application.Common.Authentication.Models.RefreshToken refreshToken, CancellationToken cancellationToken = default) + { + var entity = await _userManager.FindByIdAsync(refreshToken.UserId); + + if (entity is null) + { + throw new ValidationException(UserErrors.InvalidCredentials()); + } + + var roles = new List(); + var rolesNames = await _userManager.GetRolesAsync(entity); + + foreach (var roleName in rolesNames) + { + var role = UserRole.FromString(roleName); + + if (role is null) throw new Exception($"Role {roleName} not found"); + + roles.Add(role); + } + + return new LoginResult(entity.MapToModel(), roles); + } } \ No newline at end of file diff --git a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/JwtSettings.cs b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/Jwt/JwtSettings.cs similarity index 65% rename from dotnet/AipsCore/Infrastructure/Persistence/Authentication/JwtSettings.cs rename to dotnet/AipsCore/Infrastructure/Persistence/Authentication/Jwt/JwtSettings.cs index b095aef..a2a8bc0 100644 --- a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/JwtSettings.cs +++ b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/Jwt/JwtSettings.cs @@ -1,4 +1,4 @@ -namespace AipsCore.Infrastructure.Persistence.Authentication; +namespace AipsCore.Infrastructure.Persistence.Authentication.Jwt; public sealed class JwtSettings { @@ -6,4 +6,5 @@ public sealed class JwtSettings public string Audience { get; init; } = null!; public string Key { get; init; } = null!; public int ExpirationMinutes { get; init; } + public int RefreshTokenExpirationDays { get; init; } } \ No newline at end of file diff --git a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/JwtTokenProvider.cs b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/Jwt/JwtTokenProvider.cs similarity index 80% rename from dotnet/AipsCore/Infrastructure/Persistence/Authentication/JwtTokenProvider.cs rename to dotnet/AipsCore/Infrastructure/Persistence/Authentication/Jwt/JwtTokenProvider.cs index 2ccc79e..62b3840 100644 --- a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/JwtTokenProvider.cs +++ b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/Jwt/JwtTokenProvider.cs @@ -1,11 +1,12 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; +using System.Security.Cryptography; using System.Text; using AipsCore.Application.Abstract.UserContext; using AipsCore.Domain.Models.User.External; using Microsoft.IdentityModel.Tokens; -namespace AipsCore.Infrastructure.Persistence.Authentication; +namespace AipsCore.Infrastructure.Persistence.Authentication.Jwt; public class JwtTokenProvider : ITokenProvider { @@ -16,7 +17,7 @@ public class JwtTokenProvider : ITokenProvider _jwtSettings = jwtSettings; } - public string Generate(Domain.Models.User.User user, IList roles) + public string GenerateAccessToken(Domain.Models.User.User user, IList roles) { var claims = new List { @@ -42,4 +43,9 @@ public class JwtTokenProvider : ITokenProvider return new JwtSecurityTokenHandler().WriteToken(token); } + + public string GenerateRefreshToken() + { + return Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); + } } \ No newline at end of file diff --git a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshToken.cs b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshToken.cs new file mode 100644 index 0000000..7cce862 --- /dev/null +++ b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshToken.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace AipsCore.Infrastructure.Persistence.Authentication.RefreshToken; + +public class RefreshToken +{ + [Key] + public Guid Id { get; set; } + + [Required] + [MaxLength(255)] + public required string Token { get; set; } + + + public Guid UserId { get; set; } + public User.User User { get; set; } = null!; + + public DateTime ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshTokenException.cs b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshTokenException.cs new file mode 100644 index 0000000..7d648e2 --- /dev/null +++ b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshTokenException.cs @@ -0,0 +1,16 @@ +namespace AipsCore.Infrastructure.Persistence.Authentication.RefreshToken; + +public class RefreshTokenException : Exception +{ + private const string InvalidTokenMessage = "Invalud token"; + private const string TokenExpiredMessage = "Token expired"; + + public RefreshTokenException(string message) + : base(message) + { + + } + + public static RefreshTokenException InvalidToken() => new(InvalidTokenMessage); + public static RefreshTokenException TokenExpired() => new(TokenExpiredMessage); +} \ No newline at end of file diff --git a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshTokenMappers.cs b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshTokenMappers.cs new file mode 100644 index 0000000..cad3d2c --- /dev/null +++ b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshTokenMappers.cs @@ -0,0 +1,12 @@ +namespace AipsCore.Infrastructure.Persistence.Authentication.RefreshToken; + +public static class RefreshTokenMappers +{ + public static Application.Common.Authentication.Models.RefreshToken MapToModel(this RefreshToken entity) + { + return new Application.Common.Authentication.Models.RefreshToken( + entity.Token, + entity.UserId.ToString(), + entity.ExpiresAt); + } +} \ No newline at end of file diff --git a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshTokenRepository.cs b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshTokenRepository.cs new file mode 100644 index 0000000..0fc3880 --- /dev/null +++ b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/RefreshToken/RefreshTokenRepository.cs @@ -0,0 +1,64 @@ +using AipsCore.Application.Abstract.UserContext; +using AipsCore.Domain.Models.User.ValueObjects; +using AipsCore.Infrastructure.Persistence.Authentication.Jwt; +using AipsCore.Infrastructure.Persistence.Db; +using Microsoft.EntityFrameworkCore; + +namespace AipsCore.Infrastructure.Persistence.Authentication.RefreshToken; + +public class RefreshTokenRepository : IRefreshTokenRepository +{ + private readonly AipsDbContext _dbContext; + private readonly JwtSettings _jwtSettings; + + public RefreshTokenRepository(AipsDbContext dbContext, JwtSettings jwtSettings) + { + _dbContext = dbContext; + _jwtSettings = jwtSettings; + } + + + public async Task AddAsync(string token, UserId userId, CancellationToken cancellationToken = default) + { + var refreshToken = new RefreshToken() + { + Id = Guid.NewGuid(), + Token = token, + UserId = new Guid(userId.IdValue), + ExpiresAt = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpirationDays) + }; + + await _dbContext.AddAsync(refreshToken, cancellationToken); + } + + public async Task GetByValueAsync(string token, CancellationToken cancellationToken = default) + { + var entity = await _dbContext.RefreshTokens.FirstOrDefaultAsync(x => x.Token == token, cancellationToken); + + if (entity is null) + { + throw RefreshTokenException.InvalidToken(); + } + + if (entity.ExpiresAt < DateTime.UtcNow) + { + throw RefreshTokenException.TokenExpired(); + } + + return entity.MapToModel(); + } + + public async Task RevokeAsync(string token, CancellationToken cancellationToken = default) + { + await _dbContext.RefreshTokens + .Where(x => x.Token == token) + .ExecuteDeleteAsync(cancellationToken); + } + + public async Task RevokeAllAsync(UserId userId, CancellationToken cancellationToken = default) + { + await _dbContext.RefreshTokens + .Where(x => x.UserId == new Guid(userId.IdValue)) + .ExecuteDeleteAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/HttpUserContext.cs b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/UserContext/HttpUserContext.cs similarity index 92% rename from dotnet/AipsCore/Infrastructure/Persistence/Authentication/HttpUserContext.cs rename to dotnet/AipsCore/Infrastructure/Persistence/Authentication/UserContext/HttpUserContext.cs index a55d734..f29bdaf 100644 --- a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/HttpUserContext.cs +++ b/dotnet/AipsCore/Infrastructure/Persistence/Authentication/UserContext/HttpUserContext.cs @@ -3,7 +3,7 @@ using AipsCore.Application.Abstract.UserContext; using AipsCore.Domain.Models.User.ValueObjects; using Microsoft.AspNetCore.Http; -namespace AipsCore.Infrastructure.Persistence.Authentication; +namespace AipsCore.Infrastructure.Persistence.Authentication.UserContext; public class HttpUserContext : IUserContext {