From d470e1e6ae2507fd5a8635d9b923aba8295f2c24 Mon Sep 17 00:00:00 2001 From: Marcin Bator Date: Sun, 3 Nov 2024 21:39:29 +0100 Subject: [PATCH] feat: #39 refresh token workflow --- .github/workflows/dotnet.yml | 26 +- .../Config/ServiceRegistrationExtension.cs | 1 + .../Infrastructure/Dao/RefreshTokenDao.cs | 18 ++ .../Database/DatabaseContext.cs | 2 +- .../Database/Entity/BlacklistedJwt.cs | 15 -- .../Database/Entity/RefreshToken.cs | 16 ++ .../Background/BackgroundServiceImpl.cs | 8 +- .../Module/User/Dto/UserLoginResponse.cs | 7 + .../Module/User/UserController.cs | 38 ++- .../Infrastructure/Module/User/UserService.cs | 42 ++- rag-2-backend/Infrastructure/Util/JwtUtil.cs | 6 - .../20241103201358_RefreshToken.Designer.cs | 250 ++++++++++++++++++ .../Migrations/20241103201358_RefreshToken.cs | 61 +++++ .../DatabaseContextModelSnapshot.cs | 44 ++- rag-2-backend/Program.cs | 8 +- rag-2-backend/Test/UserServiceTest.cs | 15 +- rag-2-backend/appsettings.json | 5 +- 17 files changed, 480 insertions(+), 82 deletions(-) create mode 100644 rag-2-backend/Infrastructure/Dao/RefreshTokenDao.cs delete mode 100644 rag-2-backend/Infrastructure/Database/Entity/BlacklistedJwt.cs create mode 100644 rag-2-backend/Infrastructure/Database/Entity/RefreshToken.cs create mode 100644 rag-2-backend/Infrastructure/Module/User/Dto/UserLoginResponse.cs create mode 100644 rag-2-backend/Migrations/20241103201358_RefreshToken.Designer.cs create mode 100644 rag-2-backend/Migrations/20241103201358_RefreshToken.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 9e1553f..252b3c2 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -5,9 +5,9 @@ name: .NET on: push: - branches: ['dev', 'main'] + branches: [ 'dev', 'main' ] pull_request: - branches: ['main'] + branches: [ 'main' ] jobs: build: @@ -15,14 +15,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - name: Restore dependencies - run: dotnet restore rag-2-backend - - name: Build - run: dotnet build --no-restore rag-2-backend - - name: Test - run: dotnet test --no-build --verbosity normal rag-2-backend + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Restore dependencies + run: dotnet restore rag-2-backend + - name: Build + run: dotnet build --no-restore rag-2-backend + - name: Test + run: dotnet test --no-build --verbosity normal rag-2-backend diff --git a/rag-2-backend/Config/ServiceRegistrationExtension.cs b/rag-2-backend/Config/ServiceRegistrationExtension.cs index d26a96e..4ebe1c2 100644 --- a/rag-2-backend/Config/ServiceRegistrationExtension.cs +++ b/rag-2-backend/Config/ServiceRegistrationExtension.cs @@ -51,6 +51,7 @@ private static void ConfigServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); } diff --git a/rag-2-backend/Infrastructure/Dao/RefreshTokenDao.cs b/rag-2-backend/Infrastructure/Dao/RefreshTokenDao.cs new file mode 100644 index 0000000..9fd3573 --- /dev/null +++ b/rag-2-backend/Infrastructure/Dao/RefreshTokenDao.cs @@ -0,0 +1,18 @@ +#region + +using rag_2_backend.Infrastructure.Database; +using rag_2_backend.Infrastructure.Database.Entity; + +#endregion + +namespace rag_2_backend.Infrastructure.Dao; + +public class RefreshTokenDao(DatabaseContext context) +{ + public void RemoveTokensForUser(User user) + { + var unusedTokens = context.RefreshTokens.Where(r => r.User.Id == user.Id).ToList(); + context.RefreshTokens.RemoveRange(unusedTokens); + context.SaveChanges(); + } +} \ No newline at end of file diff --git a/rag-2-backend/Infrastructure/Database/DatabaseContext.cs b/rag-2-backend/Infrastructure/Database/DatabaseContext.cs index f28ec76..abdbfce 100644 --- a/rag-2-backend/Infrastructure/Database/DatabaseContext.cs +++ b/rag-2-backend/Infrastructure/Database/DatabaseContext.cs @@ -16,7 +16,7 @@ public class DatabaseContext(DbContextOptions options) : DbCont public virtual required DbSet RecordedGames { get; init; } public virtual required DbSet Users { get; init; } public virtual required DbSet AccountConfirmationTokens { get; init; } - public virtual required DbSet BlacklistedJwts { get; init; } + public virtual required DbSet RefreshTokens { get; init; } public virtual required DbSet PasswordResetTokens { get; init; } protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/rag-2-backend/Infrastructure/Database/Entity/BlacklistedJwt.cs b/rag-2-backend/Infrastructure/Database/Entity/BlacklistedJwt.cs deleted file mode 100644 index df3038a..0000000 --- a/rag-2-backend/Infrastructure/Database/Entity/BlacklistedJwt.cs +++ /dev/null @@ -1,15 +0,0 @@ -#region - -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -#endregion - -namespace rag_2_backend.Infrastructure.Database.Entity; - -[Table("blacklisted_jwt_table")] -public class BlacklistedJwt -{ - [Key] [MaxLength(500)] public required string Token { get; init; } - public required DateTime Expiration { get; init; } -} \ No newline at end of file diff --git a/rag-2-backend/Infrastructure/Database/Entity/RefreshToken.cs b/rag-2-backend/Infrastructure/Database/Entity/RefreshToken.cs new file mode 100644 index 0000000..be81ada --- /dev/null +++ b/rag-2-backend/Infrastructure/Database/Entity/RefreshToken.cs @@ -0,0 +1,16 @@ +#region + +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +#endregion + +namespace rag_2_backend.Infrastructure.Database.Entity; + +[Table("refresh_token_table")] +public class RefreshToken +{ + [Key] [MaxLength(100)] public required string Token { get; init; } + public required DateTime Expiration { get; set; } + public required User User { get; init; } +} \ No newline at end of file diff --git a/rag-2-backend/Infrastructure/Module/Background/BackgroundServiceImpl.cs b/rag-2-backend/Infrastructure/Module/Background/BackgroundServiceImpl.cs index dd14807..d35b391 100644 --- a/rag-2-backend/Infrastructure/Module/Background/BackgroundServiceImpl.cs +++ b/rag-2-backend/Infrastructure/Module/Background/BackgroundServiceImpl.cs @@ -19,7 +19,7 @@ protected override async Task ExecuteAsync(CancellationToken cancellationToken) while (!cancellationToken.IsCancellationRequested) { DeleteUnusedAccountTokens(); - DeleteUnusedBlacklistedJwts(); + DeleteUnusedRefreshTokens(); DeleteUnusedPasswordResetTokens(); await Task.Delay(TimeSpan.FromDays(1), cancellationToken); @@ -45,10 +45,10 @@ private void DeleteUnusedAccountTokens() Console.WriteLine("Deleted " + unconfirmedUsers.Count + " unconfirmed accounts"); } - private void DeleteUnusedBlacklistedJwts() + private void DeleteUnusedRefreshTokens() { - var unusedTokens = _dbContext.BlacklistedJwts.Where(b => b.Expiration < DateTime.Now).ToList(); - _dbContext.BlacklistedJwts.RemoveRange(unusedTokens); + var unusedTokens = _dbContext.RefreshTokens.Where(b => b.Expiration < DateTime.Now).ToList(); + _dbContext.RefreshTokens.RemoveRange(unusedTokens); _dbContext.SaveChanges(); Console.WriteLine("Deleted " + unusedTokens.Count + " blacklisted jwts"); diff --git a/rag-2-backend/Infrastructure/Module/User/Dto/UserLoginResponse.cs b/rag-2-backend/Infrastructure/Module/User/Dto/UserLoginResponse.cs new file mode 100644 index 0000000..7638ac6 --- /dev/null +++ b/rag-2-backend/Infrastructure/Module/User/Dto/UserLoginResponse.cs @@ -0,0 +1,7 @@ +namespace rag_2_backend.Infrastructure.Module.User.Dto; + +public class UserLoginResponse +{ + public required string JwtToken { get; set; } + public required string RefreshToken { get; set; } +} \ No newline at end of file diff --git a/rag-2-backend/Infrastructure/Module/User/UserController.cs b/rag-2-backend/Infrastructure/Module/User/UserController.cs index 5f1c011..8ab0e05 100644 --- a/rag-2-backend/Infrastructure/Module/User/UserController.cs +++ b/rag-2-backend/Infrastructure/Module/User/UserController.cs @@ -1,6 +1,7 @@ #region using System.ComponentModel.DataAnnotations; +using HttpExceptions.Exceptions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using rag_2_backend.Infrastructure.Dao; @@ -12,7 +13,7 @@ namespace rag_2_backend.Infrastructure.Module.User; [ApiController] [Route("api/[controller]/auth")] -public class UserController(UserService userService) : ControllerBase +public class UserController(UserService userService, IConfiguration config) : ControllerBase { /// Register new user /// User already exists or wrong study cycle year @@ -27,7 +28,23 @@ public void Register([FromBody] [Required] UserRequest userRequest) [HttpPost("login")] public string Login([FromBody] [Required] UserLoginRequest loginRequest) { - return userService.LoginUser(loginRequest.Email, loginRequest.Password); + var response = userService.LoginUser( + loginRequest.Email, + loginRequest.Password, + double.Parse(config["RefreshToken:ExpireDays"] ?? "30") + ); + + HttpContext.Response.Cookies.Append("refreshToken", response.RefreshToken, + new CookieOptions + { + Expires = DateTimeOffset.UtcNow.AddDays( + double.Parse(config["RefreshToken:ExpireDays"] ?? "30")), + HttpOnly = true + // IsEssential = true, + // Secure = true, + // SameSite = SameSiteMode.None + }); + return response.JwtToken; } /// Resend confirmation email to specified email @@ -62,15 +79,24 @@ public void ResetPassword([Required] string tokenValue, [Required] string newPas userService.ResetPassword(tokenValue, newPassword); } + /// Refresh token + /// Invalid refresh token + [HttpPost("refresh-token")] + public string RefreshToken() + { + HttpContext.Request.Cookies.TryGetValue("refreshToken", out var refreshToken); + if (refreshToken == null) + throw new UnauthorizedException("Invalid refresh token"); + + return userService.RefreshToken(refreshToken); + } + /// Logout current user (Auth) [HttpPost("logout")] [Authorize] public void Logout() { - var header = HttpContext.Request.Headers.Authorization.FirstOrDefault() ?? - throw new UnauthorizedAccessException("Unauthorized"); - - userService.LogoutUser(header); + userService.LogoutUser(UserDao.GetPrincipalEmail(User)); } /// Get current user details (Auth) diff --git a/rag-2-backend/Infrastructure/Module/User/UserService.cs b/rag-2-backend/Infrastructure/Module/User/UserService.cs index 63442a2..aeb1bc6 100644 --- a/rag-2-backend/Infrastructure/Module/User/UserService.cs +++ b/rag-2-backend/Infrastructure/Module/User/UserService.cs @@ -1,6 +1,5 @@ #region -using System.IdentityModel.Tokens.Jwt; using HttpExceptions.Exceptions; using Microsoft.EntityFrameworkCore; using rag_2_backend.Infrastructure.Common.Mapper; @@ -20,8 +19,8 @@ public class UserService( DatabaseContext context, JwtUtil jwtUtil, EmailService emailService, - JwtSecurityTokenHandler jwtSecurityTokenHandler, - UserDao userDao) + UserDao userDao, + RefreshTokenDao refreshTokenDao) { public void RegisterUser(UserRequest userRequest) { @@ -71,7 +70,7 @@ public void ConfirmAccount(string tokenValue) context.SaveChanges(); } - public string LoginUser(string email, string password) + public UserLoginResponse LoginUser(string email, string password, double refreshTokenExpirationTimeDays) { var user = userDao.GetUserByEmailOrThrow(email); @@ -82,6 +81,31 @@ public string LoginUser(string email, string password) if (user.Banned) throw new UnauthorizedException("User banned"); + var refreshToken = new RefreshToken + { + User = user, + Expiration = DateTime.Now.AddDays(refreshTokenExpirationTimeDays), + Token = Guid.NewGuid().ToString() + }; + refreshTokenDao.RemoveTokensForUser(user); + context.RefreshTokens.Add(refreshToken); + context.SaveChanges(); + + return new UserLoginResponse + { + JwtToken = jwtUtil.GenerateToken(user.Email, user.Role.ToString()), + RefreshToken = refreshToken.Token + }; + } + + public string RefreshToken(string refreshToken) + { + var token = context.RefreshTokens + .Include(t => t.User) + .SingleOrDefault(t => t.Token == refreshToken && t.Expiration > DateTime.Now) + ?? throw new UnauthorizedException("Invalid refresh token"); + var user = token.User; + return jwtUtil.GenerateToken(user.Email, user.Role.ToString()); } @@ -90,14 +114,10 @@ public UserResponse GetMe(string email) return UserMapper.Map(userDao.GetUserByEmailOrThrow(email)); } - public void LogoutUser(string header) + public void LogoutUser(string email) { - var tokenValue = header["Bearer ".Length..].Trim(); - var jwtToken = jwtSecurityTokenHandler.ReadToken(tokenValue) as JwtSecurityToken ?? - throw new UnauthorizedException("Unauthorized"); - - context.BlacklistedJwts.Add(new BlacklistedJwt { Token = tokenValue, Expiration = jwtToken.ValidTo }); - context.SaveChanges(); + var user = userDao.GetUserByEmailOrThrow(email); + refreshTokenDao.RemoveTokensForUser(user); } public void RequestPasswordReset(string email) diff --git a/rag-2-backend/Infrastructure/Util/JwtUtil.cs b/rag-2-backend/Infrastructure/Util/JwtUtil.cs index 75b713c..4613279 100644 --- a/rag-2-backend/Infrastructure/Util/JwtUtil.cs +++ b/rag-2-backend/Infrastructure/Util/JwtUtil.cs @@ -3,7 +3,6 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; -using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using rag_2_backend.Infrastructure.Database; @@ -36,9 +35,4 @@ public virtual string GenerateToken(string email, string role) return new JwtSecurityTokenHandler().WriteToken(token); } - - public async Task IsTokenBlacklistedAsync(string token) - { - return await context.BlacklistedJwts.AnyAsync(bt => bt.Token == token); - } } \ No newline at end of file diff --git a/rag-2-backend/Migrations/20241103201358_RefreshToken.Designer.cs b/rag-2-backend/Migrations/20241103201358_RefreshToken.Designer.cs new file mode 100644 index 0000000..d4689aa --- /dev/null +++ b/rag-2-backend/Migrations/20241103201358_RefreshToken.Designer.cs @@ -0,0 +1,250 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using rag_2_backend.Infrastructure.Database; + +#nullable disable + +namespace rag_2_backend.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20241103201358_RefreshToken")] + partial class RefreshToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.AccountConfirmationToken", b => + { + b.Property("Token") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Expiration") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("account_confirmation_token_table"); + }); + + modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("game_table"); + }); + + modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.GameRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndState") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Ended") + .HasColumnType("timestamp without time zone"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("OutputSpec") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Players") + .HasColumnType("text"); + + b.Property("Started") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Values") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("UserId"); + + b.ToTable("game_record_table"); + }); + + modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.PasswordResetToken", b => + { + b.Property("Token") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Expiration") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("password_reset_token_table"); + }); + + modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.RefreshToken", b => + { + b.Property("Token") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Expiration") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("refresh_token_table"); + }); + + modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Banned") + .HasColumnType("boolean"); + + b.Property("Confirmed") + .HasColumnType("boolean"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastPlayed") + .HasColumnType("timestamp without time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("StudyCycleYearA") + .HasColumnType("integer"); + + b.Property("StudyCycleYearB") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("user_table"); + }); + + modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.AccountConfirmationToken", b => + { + b.HasOne("rag_2_backend.Infrastructure.Database.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.GameRecord", b => + { + b.HasOne("rag_2_backend.Infrastructure.Database.Entity.Game", "Game") + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("rag_2_backend.Infrastructure.Database.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.PasswordResetToken", b => + { + b.HasOne("rag_2_backend.Infrastructure.Database.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.RefreshToken", b => + { + b.HasOne("rag_2_backend.Infrastructure.Database.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/rag-2-backend/Migrations/20241103201358_RefreshToken.cs b/rag-2-backend/Migrations/20241103201358_RefreshToken.cs new file mode 100644 index 0000000..21a7f4a --- /dev/null +++ b/rag-2-backend/Migrations/20241103201358_RefreshToken.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace rag_2_backend.Migrations +{ + /// + public partial class RefreshToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "blacklisted_jwt_table"); + + migrationBuilder.CreateTable( + name: "refresh_token_table", + columns: table => new + { + Token = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Expiration = table.Column(type: "timestamp without time zone", nullable: false), + UserId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_refresh_token_table", x => x.Token); + table.ForeignKey( + name: "FK_refresh_token_table_user_table_UserId", + column: x => x.UserId, + principalTable: "user_table", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_refresh_token_table_UserId", + table: "refresh_token_table", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "refresh_token_table"); + + migrationBuilder.CreateTable( + name: "blacklisted_jwt_table", + columns: table => new + { + Token = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + Expiration = table.Column(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_blacklisted_jwt_table", x => x.Token); + }); + } + } +} diff --git a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs index 002ddad..89118a8 100644 --- a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs +++ b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs @@ -41,20 +41,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("account_confirmation_token_table"); }); - modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.BlacklistedJwt", b => - { - b.Property("Token") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Expiration") - .HasColumnType("timestamp without time zone"); - - b.HasKey("Token"); - - b.ToTable("blacklisted_jwt_table"); - }); - modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.Game", b => { b.Property("Id") @@ -139,6 +125,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("password_reset_token_table"); }); + modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.RefreshToken", b => + { + b.Property("Token") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Expiration") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("refresh_token_table"); + }); + modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.User", b => { b.Property("Id") @@ -225,6 +230,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + + modelBuilder.Entity("rag_2_backend.Infrastructure.Database.Entity.RefreshToken", b => + { + b.HasOne("rag_2_backend.Infrastructure.Database.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); #pragma warning restore 612, 618 } } diff --git a/rag-2-backend/Program.cs b/rag-2-backend/Program.cs index e0795e4..7a3e1e9 100644 --- a/rag-2-backend/Program.cs +++ b/rag-2-backend/Program.cs @@ -39,15 +39,17 @@ options.Events = new JwtBearerEvents { - OnMessageReceived = async context => + OnMessageReceived = context => { var tokenBlacklistService = context.HttpContext.RequestServices.GetRequiredService(); var header = context.HttpContext.Request.Headers.Authorization.FirstOrDefault(); - if (header == null) return; + if (header == null) return Task.CompletedTask; var token = header["Bearer ".Length..].Trim(); - if (!string.IsNullOrEmpty(token) && await tokenBlacklistService.IsTokenBlacklistedAsync(token)) + if (string.IsNullOrEmpty(token)) throw new UnauthorizedException("Token is not valid"); + + return Task.CompletedTask; } }; }); diff --git a/rag-2-backend/Test/UserServiceTest.cs b/rag-2-backend/Test/UserServiceTest.cs index 67e2e60..ad1ba72 100644 --- a/rag-2-backend/Test/UserServiceTest.cs +++ b/rag-2-backend/Test/UserServiceTest.cs @@ -61,18 +61,19 @@ public UserServiceTest() }; Mock userMock = new(_contextMock.Object); + Mock refreshTokenDaoMock = new(_contextMock.Object); userMock.Setup(u => u.GetUserByIdOrThrow(It.IsAny())).Returns(_user); userMock.Setup(u => u.GetUserByEmailOrThrow(It.IsAny())).Returns(_user); _userService = new UserService(_contextMock.Object, _jwtUtilMock.Object, _emailService.Object, - _jwtSecurityTokenHandlerMock.Object, userMock.Object); + userMock.Object, refreshTokenDaoMock.Object); _contextMock.Setup(c => c.Users).Returns(() => new List { _user } .AsQueryable().BuildMockDbSet().Object); _contextMock.Setup(c => c.AccountConfirmationTokens) .Returns(() => new List { _accountToken }.AsQueryable().BuildMockDbSet().Object); - _contextMock.Setup(c => c.BlacklistedJwts) - .Returns(() => new List().AsQueryable().BuildMockDbSet().Object); + _contextMock.Setup(c => c.RefreshTokens) + .Returns(() => new List().AsQueryable().BuildMockDbSet().Object); _contextMock.Setup(c => c.RecordedGames) .Returns(() => new List().AsQueryable().BuildMockDbSet().Object); _contextMock.Setup(c => c.PasswordResetTokens) @@ -134,15 +135,15 @@ public void ShouldConfirmAccount() public void ShouldLoginUser() { Assert.Throws( - () => _userService.LoginUser("email@prz.edu.pl", "pass") + () => _userService.LoginUser("email@prz.edu.pl", "pass", 30) ); //wrong password Assert.Throws( - () => _userService.LoginUser("email@prz.edu.pl", "password") + () => _userService.LoginUser("email@prz.edu.pl", "password", 30) ); //not confirmed _user.Confirmed = true; - _userService.LoginUser("email@prz.edu.pl", "password"); + _userService.LoginUser("email@prz.edu.pl", "password", 30); _jwtUtilMock.Verify(j => j.GenerateToken(It.IsAny(), It.IsAny()), Times.Once); } @@ -150,8 +151,6 @@ public void ShouldLoginUser() public void ShouldLogoutUser() { _userService.LogoutUser("Bearer header"); - - _contextMock.Verify(c => c.BlacklistedJwts.Add(It.IsAny()), Times.Once); } [Fact] diff --git a/rag-2-backend/appsettings.json b/rag-2-backend/appsettings.json index 23dfd98..6fbda5c 100644 --- a/rag-2-backend/appsettings.json +++ b/rag-2-backend/appsettings.json @@ -8,7 +8,10 @@ "AllowedHosts": "*", "Jwt": { "Issuer": "immortalcoders.com", - "ExpireMinutes": 60 + "ExpireMinutes": 1 + }, + "RefreshToken": { + "ExpireDays": 30 }, "MailSettings": { "Host": "smtp.gmail.com",