diff --git a/dotnet/AipsCore/Application/Abstract/UserContext/IRefreshTokenManager.cs b/dotnet/AipsCore/Application/Abstract/UserContext/IRefreshTokenManager.cs new file mode 100644 index 0000000..7610e12 --- /dev/null +++ b/dotnet/AipsCore/Application/Abstract/UserContext/IRefreshTokenManager.cs @@ -0,0 +1,12 @@ +using AipsCore.Application.Common.Authentication.Models; +using AipsCore.Domain.Models.User.ValueObjects; + +namespace AipsCore.Application.Abstract.UserContext; + +public interface IRefreshTokenManager +{ + Task AddAsync(string token, UserId userId, CancellationToken cancellationToken = default); + Task GetByValueAsync(string token, CancellationToken cancellationToken = default); + Task RevokeAsync(string token, CancellationToken cancellationToken = default); + Task RevokeAllAsync(UserId userId, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Abstract/UserContext/ITokenProvider.cs b/dotnet/AipsCore/Application/Abstract/UserContext/ITokenProvider.cs index 26932bf..15de6e8 100644 --- a/dotnet/AipsCore/Application/Abstract/UserContext/ITokenProvider.cs +++ b/dotnet/AipsCore/Application/Abstract/UserContext/ITokenProvider.cs @@ -5,5 +5,6 @@ namespace AipsCore.Application.Abstract.UserContext; public interface ITokenProvider { - string Generate(User user, IList roles); + string GenerateAccessToken(User user, IList roles); + string GenerateRefreshToken(); } \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Common/Authentication/Dtos/LogInUserResultDto.cs b/dotnet/AipsCore/Application/Common/Authentication/Dtos/LogInUserResultDto.cs new file mode 100644 index 0000000..43317eb --- /dev/null +++ b/dotnet/AipsCore/Application/Common/Authentication/Dtos/LogInUserResultDto.cs @@ -0,0 +1,3 @@ +namespace AipsCore.Application.Common.Authentication.Dtos; + +public record LogInUserResultDto(string AccessToken, string RefreshToken); \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Common/Authentication/IAuthService.cs b/dotnet/AipsCore/Application/Common/Authentication/IAuthService.cs index f5c3215..abd9b4d 100644 --- a/dotnet/AipsCore/Application/Common/Authentication/IAuthService.cs +++ b/dotnet/AipsCore/Application/Common/Authentication/IAuthService.cs @@ -1,3 +1,4 @@ +using AipsCore.Application.Common.Authentication.Models; using AipsCore.Domain.Models.User; namespace AipsCore.Application.Common.Authentication; @@ -6,4 +7,5 @@ public interface IAuthService { Task SignUpWithPasswordAsync(User user, string password, CancellationToken cancellationToken = default); Task LoginWithEmailAndPasswordAsync(string email, string password, CancellationToken cancellationToken = default); + Task LoginWithRefreshTokenAsync(RefreshToken refreshToken, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Common/Authentication/Models/AccessToken.cs b/dotnet/AipsCore/Application/Common/Authentication/Models/AccessToken.cs new file mode 100644 index 0000000..4ac3966 --- /dev/null +++ b/dotnet/AipsCore/Application/Common/Authentication/Models/AccessToken.cs @@ -0,0 +1,3 @@ +namespace AipsCore.Application.Common.Authentication.Models; + +public record AccessToken(string Value); \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Common/Authentication/Models/RefreshToken.cs b/dotnet/AipsCore/Application/Common/Authentication/Models/RefreshToken.cs new file mode 100644 index 0000000..7ec8786 --- /dev/null +++ b/dotnet/AipsCore/Application/Common/Authentication/Models/RefreshToken.cs @@ -0,0 +1,5 @@ +using AipsCore.Domain.Models.User.ValueObjects; + +namespace AipsCore.Application.Common.Authentication.Models; + +public record RefreshToken(string Value, string UserId, DateTime ExpiresAt); \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Common/Authentication/Token.cs b/dotnet/AipsCore/Application/Common/Authentication/Token.cs deleted file mode 100644 index 18d5af1..0000000 --- a/dotnet/AipsCore/Application/Common/Authentication/Token.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace AipsCore.Application.Common.Authentication; - -public record Token(string Value); \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Models/User/Command/LogIn/LogInUserCommand.cs b/dotnet/AipsCore/Application/Models/User/Command/LogIn/LogInUserCommand.cs index 216eb8c..677be8e 100644 --- a/dotnet/AipsCore/Application/Models/User/Command/LogIn/LogInUserCommand.cs +++ b/dotnet/AipsCore/Application/Models/User/Command/LogIn/LogInUserCommand.cs @@ -1,6 +1,7 @@ using AipsCore.Application.Abstract.Command; using AipsCore.Application.Common.Authentication; +using AipsCore.Application.Common.Authentication.Dtos; namespace AipsCore.Application.Models.User.Command.LogIn; -public record LogInUserCommand(string Email, string Password) : ICommand; \ No newline at end of file +public record LogInUserCommand(string Email, string Password) : ICommand; \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Models/User/Command/LogIn/LogInUserCommandHandler.cs b/dotnet/AipsCore/Application/Models/User/Command/LogIn/LogInUserCommandHandler.cs index eebaa7d..6936746 100644 --- a/dotnet/AipsCore/Application/Models/User/Command/LogIn/LogInUserCommandHandler.cs +++ b/dotnet/AipsCore/Application/Models/User/Command/LogIn/LogInUserCommandHandler.cs @@ -1,28 +1,40 @@ using AipsCore.Application.Abstract.Command; using AipsCore.Application.Abstract.UserContext; using AipsCore.Application.Common.Authentication; +using AipsCore.Application.Common.Authentication.Dtos; using AipsCore.Domain.Abstract; -using AipsCore.Domain.Models.User.External; namespace AipsCore.Application.Models.User.Command.LogIn; -public class LogInUserCommandHandler : ICommandHandler +public class LogInUserCommandHandler : ICommandHandler { - private readonly IUserRepository _userRepository; private readonly ITokenProvider _tokenProvider; + private readonly IRefreshTokenManager _refreshTokenManager; private readonly IAuthService _authService; + private readonly IUnitOfWork _unitOfWork; - public LogInUserCommandHandler(IUserRepository userRepository, ITokenProvider tokenProvider, IAuthService authService) + public LogInUserCommandHandler( + ITokenProvider tokenProvider, + IRefreshTokenManager refreshTokenManager, + IAuthService authService, + IUnitOfWork unitOfWork) { - _userRepository = userRepository; _tokenProvider = tokenProvider; + _refreshTokenManager = refreshTokenManager; _authService = authService; + _unitOfWork = unitOfWork; } - public async Task Handle(LogInUserCommand command, CancellationToken cancellationToken = default) + public async Task Handle(LogInUserCommand command, CancellationToken cancellationToken = default) { var loginResult = await _authService.LoginWithEmailAndPasswordAsync(command.Email, command.Password, cancellationToken); - return new Token(_tokenProvider.Generate(loginResult.User, loginResult.Roles)); + var accessToken = _tokenProvider.GenerateAccessToken(loginResult.User, loginResult.Roles); + var refreshToken = _tokenProvider.GenerateRefreshToken(); + + await _refreshTokenManager.AddAsync(refreshToken, loginResult.User.Id, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return new LogInUserResultDto(accessToken, refreshToken); } } \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Models/User/Command/LogOut/LogOutCommand.cs b/dotnet/AipsCore/Application/Models/User/Command/LogOut/LogOutCommand.cs new file mode 100644 index 0000000..4dba5f9 --- /dev/null +++ b/dotnet/AipsCore/Application/Models/User/Command/LogOut/LogOutCommand.cs @@ -0,0 +1,5 @@ +using AipsCore.Application.Abstract.Command; + +namespace AipsCore.Application.Models.User.Command.LogOut; + +public record LogOutCommand(string RefreshToken) : ICommand; \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Models/User/Command/LogOut/LogOutCommandHandler.cs b/dotnet/AipsCore/Application/Models/User/Command/LogOut/LogOutCommandHandler.cs new file mode 100644 index 0000000..5953890 --- /dev/null +++ b/dotnet/AipsCore/Application/Models/User/Command/LogOut/LogOutCommandHandler.cs @@ -0,0 +1,19 @@ +using AipsCore.Application.Abstract.Command; +using AipsCore.Application.Abstract.UserContext; + +namespace AipsCore.Application.Models.User.Command.LogOut; + +public class LogOutCommandHandler : ICommandHandler +{ + private readonly IRefreshTokenManager _refreshTokenManager; + + public LogOutCommandHandler(IRefreshTokenManager refreshTokenManager) + { + _refreshTokenManager = refreshTokenManager; + } + + public async Task Handle(LogOutCommand command, CancellationToken cancellationToken = default) + { + await _refreshTokenManager.RevokeAsync(command.RefreshToken, cancellationToken); + } +} \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Models/User/Command/LogOutAll/LogOutAllCommand.cs b/dotnet/AipsCore/Application/Models/User/Command/LogOutAll/LogOutAllCommand.cs new file mode 100644 index 0000000..da0f25c --- /dev/null +++ b/dotnet/AipsCore/Application/Models/User/Command/LogOutAll/LogOutAllCommand.cs @@ -0,0 +1,5 @@ +using AipsCore.Application.Abstract.Command; + +namespace AipsCore.Application.Models.User.Command.LogOutAll; + +public record LogOutAllCommand : ICommand; \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Models/User/Command/LogOutAll/LogOutAllCommandHandler.cs b/dotnet/AipsCore/Application/Models/User/Command/LogOutAll/LogOutAllCommandHandler.cs new file mode 100644 index 0000000..46ba9d3 --- /dev/null +++ b/dotnet/AipsCore/Application/Models/User/Command/LogOutAll/LogOutAllCommandHandler.cs @@ -0,0 +1,23 @@ +using AipsCore.Application.Abstract.Command; +using AipsCore.Application.Abstract.UserContext; + +namespace AipsCore.Application.Models.User.Command.LogOutAll; + +public class LogOutAllCommandHandler : ICommandHandler +{ + private readonly IRefreshTokenManager _refreshTokenManager; + private readonly IUserContext _userContext; + + public LogOutAllCommandHandler(IRefreshTokenManager refreshTokenManager, IUserContext userContext) + { + _refreshTokenManager = refreshTokenManager; + _userContext = userContext; + } + + public Task Handle(LogOutAllCommand command, CancellationToken cancellationToken = default) + { + var userId = _userContext.GetCurrentUserId(); + + return _refreshTokenManager.RevokeAllAsync(userId, cancellationToken); + } +} \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Models/User/Command/RefreshLogIn/RefreshLogInCommand.cs b/dotnet/AipsCore/Application/Models/User/Command/RefreshLogIn/RefreshLogInCommand.cs new file mode 100644 index 0000000..12c856b --- /dev/null +++ b/dotnet/AipsCore/Application/Models/User/Command/RefreshLogIn/RefreshLogInCommand.cs @@ -0,0 +1,6 @@ +using AipsCore.Application.Abstract.Command; +using AipsCore.Application.Common.Authentication.Dtos; + +namespace AipsCore.Application.Models.User.Command.RefreshLogIn; + +public record RefreshLogInCommand(string RefreshToken) : ICommand; \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Models/User/Command/RefreshLogIn/RefreshLogInCommandHandler.cs b/dotnet/AipsCore/Application/Models/User/Command/RefreshLogIn/RefreshLogInCommandHandler.cs new file mode 100644 index 0000000..e725d63 --- /dev/null +++ b/dotnet/AipsCore/Application/Models/User/Command/RefreshLogIn/RefreshLogInCommandHandler.cs @@ -0,0 +1,44 @@ +using AipsCore.Application.Abstract.Command; +using AipsCore.Application.Abstract.UserContext; +using AipsCore.Application.Common.Authentication; +using AipsCore.Application.Common.Authentication.Dtos; +using AipsCore.Domain.Abstract; + +namespace AipsCore.Application.Models.User.Command.RefreshLogIn; + +public class RefreshLogInCommandHandler : ICommandHandler +{ + private readonly ITokenProvider _tokenProvider; + private readonly IRefreshTokenManager _refreshTokenManager; + private readonly IAuthService _authService; + private readonly IUnitOfWork _unitOfWork; + + public RefreshLogInCommandHandler( + ITokenProvider tokenProvider, + IRefreshTokenManager refreshTokenManager, + IAuthService authService, + IUnitOfWork unitOfWork) + { + _tokenProvider = tokenProvider; + _refreshTokenManager = refreshTokenManager; + _authService = authService; + _unitOfWork = unitOfWork; + } + + public async Task Handle(RefreshLogInCommand command, CancellationToken cancellationToken = default) + { + var refreshToken = await _refreshTokenManager.GetByValueAsync(command.RefreshToken, cancellationToken); + + var loginResult = await _authService.LoginWithRefreshTokenAsync(refreshToken, cancellationToken); + + var newAccessToken = _tokenProvider.GenerateAccessToken(loginResult.User, loginResult.Roles); + var newRefreshToken = _tokenProvider.GenerateRefreshToken(); + + await _refreshTokenManager.RevokeAsync(refreshToken.Value, cancellationToken); + await _refreshTokenManager.AddAsync(newRefreshToken, loginResult.User.Id, cancellationToken); + + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return new LogInUserResultDto(newAccessToken, newRefreshToken); + } +} \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Models/User/Command/SignUp/SignUpUserCommandHandler.cs b/dotnet/AipsCore/Application/Models/User/Command/SignUp/SignUpUserCommandHandler.cs index 925c22f..0c67df8 100644 --- a/dotnet/AipsCore/Application/Models/User/Command/SignUp/SignUpUserCommandHandler.cs +++ b/dotnet/AipsCore/Application/Models/User/Command/SignUp/SignUpUserCommandHandler.cs @@ -1,19 +1,15 @@ using AipsCore.Application.Abstract.Command; using AipsCore.Application.Common.Authentication; -using AipsCore.Domain.Abstract; -using AipsCore.Domain.Models.User.External; using AipsCore.Domain.Models.User.ValueObjects; namespace AipsCore.Application.Models.User.Command.SignUp; public class SignUpUserCommandHandler : ICommandHandler { - private readonly IUserRepository _userRepository; private readonly IAuthService _authService; - public SignUpUserCommandHandler(IUserRepository userRepository, IAuthService authService) + public SignUpUserCommandHandler(IAuthService authService) { - _userRepository = userRepository; _authService = authService; } diff --git a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/EfAuthService.cs b/dotnet/AipsCore/Infrastructure/Authentication/AuthService/EfAuthService.cs similarity index 62% rename from dotnet/AipsCore/Infrastructure/Persistence/Authentication/EfAuthService.cs rename to dotnet/AipsCore/Infrastructure/Authentication/AuthService/EfAuthService.cs index 700bf42..84f6c74 100644 --- a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/EfAuthService.cs +++ b/dotnet/AipsCore/Infrastructure/Authentication/AuthService/EfAuthService.cs @@ -2,17 +2,20 @@ 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; -namespace AipsCore.Infrastructure.Persistence.Authentication; +namespace AipsCore.Infrastructure.Authentication.AuthService; public class EfAuthService : IAuthService { - private readonly UserManager _userManager; + private readonly AipsDbContext _dbContext; + private readonly UserManager _userManager; - public EfAuthService(UserManager userManager) + public EfAuthService(AipsDbContext dbContext, UserManager userManager) { + _dbContext = dbContext; _userManager = userManager; } @@ -61,4 +64,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/Authentication/Jwt/JwtSettings.cs similarity index 67% rename from dotnet/AipsCore/Infrastructure/Persistence/Authentication/JwtSettings.cs rename to dotnet/AipsCore/Infrastructure/Authentication/Jwt/JwtSettings.cs index b095aef..42c6df6 100644 --- a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/JwtSettings.cs +++ b/dotnet/AipsCore/Infrastructure/Authentication/Jwt/JwtSettings.cs @@ -1,4 +1,4 @@ -namespace AipsCore.Infrastructure.Persistence.Authentication; +namespace AipsCore.Infrastructure.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/Authentication/Jwt/JwtTokenProvider.cs similarity index 80% rename from dotnet/AipsCore/Infrastructure/Persistence/Authentication/JwtTokenProvider.cs rename to dotnet/AipsCore/Infrastructure/Authentication/Jwt/JwtTokenProvider.cs index 2ccc79e..2539910 100644 --- a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/JwtTokenProvider.cs +++ b/dotnet/AipsCore/Infrastructure/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.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/HttpUserContext.cs b/dotnet/AipsCore/Infrastructure/Authentication/UserContext/HttpUserContext.cs similarity index 93% rename from dotnet/AipsCore/Infrastructure/Persistence/Authentication/HttpUserContext.cs rename to dotnet/AipsCore/Infrastructure/Authentication/UserContext/HttpUserContext.cs index a55d734..c8eb474 100644 --- a/dotnet/AipsCore/Infrastructure/Persistence/Authentication/HttpUserContext.cs +++ b/dotnet/AipsCore/Infrastructure/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.Authentication.UserContext; public class HttpUserContext : IUserContext { diff --git a/dotnet/AipsCore/Infrastructure/DI/Configuration/ConfigurationEnvExtensions.cs b/dotnet/AipsCore/Infrastructure/DI/Configuration/ConfigurationEnvExtensions.cs index da94672..ef4f089 100644 --- a/dotnet/AipsCore/Infrastructure/DI/Configuration/ConfigurationEnvExtensions.cs +++ b/dotnet/AipsCore/Infrastructure/DI/Configuration/ConfigurationEnvExtensions.cs @@ -13,6 +13,7 @@ public static class ConfigurationEnvExtensions private const string JwtAudience = "JWT_AUDIENCE"; private const string JwtKey = "JWT_KEY"; private const string JwtExpirationMinutes = "JWT_EXPIRATION_MINUTES"; + private const string JwtRefreshExpirationDays = "JWT_REFRESH_TOKEN_EXPIRATION_DAYS"; extension(IConfiguration configuration) { @@ -51,6 +52,11 @@ public static class ConfigurationEnvExtensions return configuration.GetEnvInt(configuration.GetEnvOrDefault(JwtExpirationMinutes, "60")); } + public int GetEnvJwtRefreshExpirationDays() + { + return configuration.GetEnvInt(configuration.GetEnvOrDefault(JwtRefreshExpirationDays, "7")); + } + private string GetEnvForSure(string key) { var value = configuration[key]; diff --git a/dotnet/AipsCore/Infrastructure/DI/PersistenceRegistrationExtensions.cs b/dotnet/AipsCore/Infrastructure/DI/PersistenceRegistrationExtensions.cs index 5f1faac..4a67c18 100644 --- a/dotnet/AipsCore/Infrastructure/DI/PersistenceRegistrationExtensions.cs +++ b/dotnet/AipsCore/Infrastructure/DI/PersistenceRegistrationExtensions.cs @@ -1,3 +1,4 @@ +using AipsCore.Application.Abstract.UserContext; using AipsCore.Domain.Abstract; using AipsCore.Domain.Models.Shape.External; using AipsCore.Domain.Models.User.External; @@ -5,6 +6,7 @@ using AipsCore.Domain.Models.Whiteboard.External; using AipsCore.Domain.Models.WhiteboardMembership.External; using AipsCore.Infrastructure.DI.Configuration; using AipsCore.Infrastructure.Persistence.Db; +using AipsCore.Infrastructure.Persistence.RefreshToken; using AipsCore.Infrastructure.Persistence.Shape; using AipsCore.Infrastructure.Persistence.User; using AipsCore.Infrastructure.Persistence.Whiteboard; @@ -28,10 +30,13 @@ public static class PersistenceRegistrationExtensions }); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); + + services.AddTransient(); return services; } diff --git a/dotnet/AipsCore/Infrastructure/DI/UserContextRegistrationExtension.cs b/dotnet/AipsCore/Infrastructure/DI/UserContextRegistrationExtension.cs index e3e5ea1..76a3e13 100644 --- a/dotnet/AipsCore/Infrastructure/DI/UserContextRegistrationExtension.cs +++ b/dotnet/AipsCore/Infrastructure/DI/UserContextRegistrationExtension.cs @@ -2,8 +2,10 @@ using System.Text; using AipsCore.Application.Abstract.UserContext; using AipsCore.Application.Common.Authentication; using AipsCore.Domain.Models.User.Options; +using AipsCore.Infrastructure.Authentication.AuthService; +using AipsCore.Infrastructure.Authentication.Jwt; +using AipsCore.Infrastructure.Authentication.UserContext; using AipsCore.Infrastructure.DI.Configuration; -using AipsCore.Infrastructure.Persistence.Authentication; using AipsCore.Infrastructure.Persistence.Db; using AipsCore.Infrastructure.Persistence.User; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -23,7 +25,8 @@ public static class UserContextRegistrationExtension Issuer = configuration.GetEnvJwtIssuer(), Audience = configuration.GetEnvJwtAudience(), Key = configuration.GetEnvJwtKey(), - ExpirationMinutes = configuration.GetEnvJwtExpirationMinutes() + ExpirationMinutes = configuration.GetEnvJwtExpirationMinutes(), + RefreshTokenExpirationDays = configuration.GetEnvJwtRefreshExpirationDays() }; services.AddSingleton(jwtSettings); @@ -50,6 +53,8 @@ public static class UserContextRegistrationExtension { options.TokenValidationParameters = new TokenValidationParameters { + ClockSkew = TimeSpan.FromSeconds(30), + ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, diff --git a/dotnet/AipsCore/Infrastructure/Persistence/Db/AipsDbContext.cs b/dotnet/AipsCore/Infrastructure/Persistence/Db/AipsDbContext.cs index 5aff8f0..a71f367 100644 --- a/dotnet/AipsCore/Infrastructure/Persistence/Db/AipsDbContext.cs +++ b/dotnet/AipsCore/Infrastructure/Persistence/Db/AipsDbContext.cs @@ -6,6 +6,8 @@ namespace AipsCore.Infrastructure.Persistence.Db; public class AipsDbContext : IdentityDbContext, Guid> { + public DbSet RefreshTokens { get; set; } + public DbSet Whiteboards { get; set; } public DbSet Shapes { get; set; } public DbSet WhiteboardMemberships { get; set; } diff --git a/dotnet/AipsCore/Infrastructure/Persistence/Db/Migrations/20260214171247_AddedRefreshTokens.Designer.cs b/dotnet/AipsCore/Infrastructure/Persistence/Db/Migrations/20260214171247_AddedRefreshTokens.Designer.cs new file mode 100644 index 0000000..179c3a3 --- /dev/null +++ b/dotnet/AipsCore/Infrastructure/Persistence/Db/Migrations/20260214171247_AddedRefreshTokens.Designer.cs @@ -0,0 +1,506 @@ +// +using System; +using AipsCore.Infrastructure.Persistence.Db; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AipsCore.Infrastructure.Persistence.Db.Migrations +{ + [DbContext(typeof(AipsDbContext))] + [Migration("20260214171247_AddedRefreshTokens")] + partial class AddedRefreshTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.Authentication.RefreshToken.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.Shape.Shape", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("EndPositionX") + .HasColumnType("integer"); + + b.Property("EndPositionY") + .HasColumnType("integer"); + + b.Property("PositionX") + .HasColumnType("integer"); + + b.Property("PositionY") + .HasColumnType("integer"); + + b.Property("TextSize") + .HasColumnType("integer"); + + b.Property("TextValue") + .HasColumnType("text"); + + b.Property("Thickness") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("WhiteboardId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("WhiteboardId"); + + b.ToTable("Shapes"); + }); + + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.Whiteboard.Whiteboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JoinPolicy") + .HasColumnType("integer"); + + b.Property("MaxParticipants") + .HasColumnType("integer"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Whiteboards"); + }); + + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.WhiteboardMembership.WhiteboardMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanJoin") + .HasColumnType("boolean"); + + b.Property("EditingEnabled") + .HasColumnType("boolean"); + + b.Property("IsBanned") + .HasColumnType("boolean"); + + b.Property("LastInteractedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("WhiteboardId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WhiteboardId"); + + b.ToTable("WhiteboardMemberships"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.Authentication.RefreshToken.RefreshToken", b => + { + b.HasOne("AipsCore.Infrastructure.Persistence.User.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.Shape.Shape", b => + { + b.HasOne("AipsCore.Infrastructure.Persistence.User.User", "Author") + .WithMany("Shapes") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AipsCore.Infrastructure.Persistence.Whiteboard.Whiteboard", "Whiteboard") + .WithMany("Shapes") + .HasForeignKey("WhiteboardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("Whiteboard"); + }); + + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.Whiteboard.Whiteboard", b => + { + b.HasOne("AipsCore.Infrastructure.Persistence.User.User", "Owner") + .WithMany("Whiteboards") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.WhiteboardMembership.WhiteboardMembership", b => + { + b.HasOne("AipsCore.Infrastructure.Persistence.User.User", "User") + .WithMany("Memberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AipsCore.Infrastructure.Persistence.Whiteboard.Whiteboard", "Whiteboard") + .WithMany("Memberships") + .HasForeignKey("WhiteboardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("Whiteboard"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("AipsCore.Infrastructure.Persistence.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("AipsCore.Infrastructure.Persistence.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AipsCore.Infrastructure.Persistence.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("AipsCore.Infrastructure.Persistence.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.User.User", b => + { + b.Navigation("Memberships"); + + b.Navigation("Shapes"); + + b.Navigation("Whiteboards"); + }); + + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.Whiteboard.Whiteboard", b => + { + b.Navigation("Memberships"); + + b.Navigation("Shapes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/dotnet/AipsCore/Infrastructure/Persistence/Db/Migrations/20260214171247_AddedRefreshTokens.cs b/dotnet/AipsCore/Infrastructure/Persistence/Db/Migrations/20260214171247_AddedRefreshTokens.cs new file mode 100644 index 0000000..11fde22 --- /dev/null +++ b/dotnet/AipsCore/Infrastructure/Persistence/Db/Migrations/20260214171247_AddedRefreshTokens.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AipsCore.Infrastructure.Persistence.Db.Migrations +{ + /// + public partial class AddedRefreshTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Token = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + table.ForeignKey( + name: "FK_RefreshTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RefreshTokens"); + } + } +} diff --git a/dotnet/AipsCore/Infrastructure/Persistence/Db/Migrations/AipsDbContextModelSnapshot.cs b/dotnet/AipsCore/Infrastructure/Persistence/Db/Migrations/AipsDbContextModelSnapshot.cs index a0c1825..fe16030 100644 --- a/dotnet/AipsCore/Infrastructure/Persistence/Db/Migrations/AipsDbContextModelSnapshot.cs +++ b/dotnet/AipsCore/Infrastructure/Persistence/Db/Migrations/AipsDbContextModelSnapshot.cs @@ -22,6 +22,30 @@ namespace AipsCore.Infrastructure.Persistence.Db.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.Authentication.RefreshToken.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.Shape.Shape", b => { b.Property("Id") @@ -347,6 +371,17 @@ namespace AipsCore.Infrastructure.Persistence.Db.Migrations b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.Authentication.RefreshToken.RefreshToken", b => + { + b.HasOne("AipsCore.Infrastructure.Persistence.User.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("AipsCore.Infrastructure.Persistence.Shape.Shape", b => { b.HasOne("AipsCore.Infrastructure.Persistence.User.User", "Author") diff --git a/dotnet/AipsCore/Infrastructure/DI/StartupExtensions.cs b/dotnet/AipsCore/Infrastructure/Persistence/Db/StartupExtensions.cs similarity index 81% rename from dotnet/AipsCore/Infrastructure/DI/StartupExtensions.cs rename to dotnet/AipsCore/Infrastructure/Persistence/Db/StartupExtensions.cs index 0a7265e..d9c65e3 100644 --- a/dotnet/AipsCore/Infrastructure/DI/StartupExtensions.cs +++ b/dotnet/AipsCore/Infrastructure/Persistence/Db/StartupExtensions.cs @@ -1,7 +1,6 @@ -using AipsCore.Infrastructure.Persistence.Db; using Microsoft.Extensions.DependencyInjection; -namespace AipsCore.Infrastructure.DI; +namespace AipsCore.Infrastructure.Persistence.Db; public static class StartupExtensions { diff --git a/dotnet/AipsCore/Infrastructure/Persistence/RefreshToken/RefreshToken.cs b/dotnet/AipsCore/Infrastructure/Persistence/RefreshToken/RefreshToken.cs new file mode 100644 index 0000000..a762b50 --- /dev/null +++ b/dotnet/AipsCore/Infrastructure/Persistence/RefreshToken/RefreshToken.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace AipsCore.Infrastructure.Persistence.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/RefreshToken/RefreshTokenException.cs b/dotnet/AipsCore/Infrastructure/Persistence/RefreshToken/RefreshTokenException.cs new file mode 100644 index 0000000..10ab30c --- /dev/null +++ b/dotnet/AipsCore/Infrastructure/Persistence/RefreshToken/RefreshTokenException.cs @@ -0,0 +1,16 @@ +namespace AipsCore.Infrastructure.Persistence.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/RefreshToken/RefreshTokenManager.cs b/dotnet/AipsCore/Infrastructure/Persistence/RefreshToken/RefreshTokenManager.cs new file mode 100644 index 0000000..29e0e7f --- /dev/null +++ b/dotnet/AipsCore/Infrastructure/Persistence/RefreshToken/RefreshTokenManager.cs @@ -0,0 +1,64 @@ +using AipsCore.Application.Abstract.UserContext; +using AipsCore.Domain.Models.User.ValueObjects; +using AipsCore.Infrastructure.Authentication.Jwt; +using AipsCore.Infrastructure.Persistence.Db; +using Microsoft.EntityFrameworkCore; + +namespace AipsCore.Infrastructure.Persistence.RefreshToken; + +public class RefreshTokenManager : IRefreshTokenManager +{ + private readonly AipsDbContext _dbContext; + private readonly JwtSettings _jwtSettings; + + public RefreshTokenManager(AipsDbContext dbContext, JwtSettings jwtSettings) + { + _dbContext = dbContext; + _jwtSettings = jwtSettings; + } + + + public async Task AddAsync(string token, UserId userId, CancellationToken cancellationToken = default) + { + var refreshToken = new Persistence.RefreshToken.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/RefreshToken/RefreshTokenMappers.cs b/dotnet/AipsCore/Infrastructure/Persistence/RefreshToken/RefreshTokenMappers.cs new file mode 100644 index 0000000..13b1774 --- /dev/null +++ b/dotnet/AipsCore/Infrastructure/Persistence/RefreshToken/RefreshTokenMappers.cs @@ -0,0 +1,12 @@ +namespace AipsCore.Infrastructure.Persistence.RefreshToken; + +public static class RefreshTokenMappers +{ + public static Application.Common.Authentication.Models.RefreshToken MapToModel(this Persistence.RefreshToken.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/AipsWebApi/Controllers/UserController.cs b/dotnet/AipsWebApi/Controllers/UserController.cs index 4f2e92f..e526533 100644 --- a/dotnet/AipsWebApi/Controllers/UserController.cs +++ b/dotnet/AipsWebApi/Controllers/UserController.cs @@ -1,4 +1,5 @@ using AipsCore.Application.Abstract; +using AipsCore.Application.Common.Authentication.Dtos; using AipsCore.Application.Abstract.MessageBroking; using AipsCore.Application.Common.Authentication; using AipsCore.Application.Common.Message.TestMessage; @@ -31,10 +32,34 @@ public class UserController : ControllerBase [AllowAnonymous] [HttpPost("login")] - public async Task> LogIn(LogInUserCommand command, CancellationToken cancellationToken) + public async Task> LogIn(LogInUserCommand command, CancellationToken cancellationToken) { var result = await _dispatcher.Execute(command, cancellationToken); - return Ok(result.Value); + return Ok(result); + } + + [AllowAnonymous] + [HttpPost("refresh-login")] + public async Task> RefreshLogIn(RefreshLogInCommand command, CancellationToken cancellationToken) + { + var result = await _dispatcher.Execute(command, cancellationToken); + return Ok(result); + } + + [Authorize] + [HttpDelete("logout")] + public async Task LogOut(LogOutCommand command, CancellationToken cancellationToken) + { + await _dispatcher.Execute(command, cancellationToken); + return Ok(); + } + + [Authorize] + [HttpDelete("logout-all")] + public async Task LogOutAll(LogOutAllCommand command, CancellationToken cancellationToken) + { + await _dispatcher.Execute(command, cancellationToken); + return Ok(); } [AllowAnonymous] diff --git a/dotnet/AipsWebApi/Program.cs b/dotnet/AipsWebApi/Program.cs index f6187c1..25377d7 100644 --- a/dotnet/AipsWebApi/Program.cs +++ b/dotnet/AipsWebApi/Program.cs @@ -1,4 +1,5 @@ using AipsCore.Infrastructure.DI; +using AipsCore.Infrastructure.Persistence.Db; using AipsWebApi.Middleware; using DotNetEnv;