diff --git a/coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.Designer.cs b/coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.Designer.cs similarity index 99% rename from coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.Designer.cs rename to coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.Designer.cs index 749c3140..e6e5f7e1 100644 --- a/coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.Designer.cs +++ b/coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.Designer.cs @@ -12,7 +12,7 @@ namespace CoffeeCard.Library.Migrations { [DbContext(typeof(CoffeeCardContext))] - [Migration("20241001160733_AddTokenProperties")] + [Migration("20241022153731_AddTokenProperties")] partial class AddTokenProperties { /// @@ -312,9 +312,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Expires") .HasColumnType("datetime2"); - b.Property("PreviousTokenId") - .HasColumnType("int"); - b.Property("Revoked") .HasColumnType("bit"); diff --git a/coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.cs b/coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.cs similarity index 82% rename from coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.cs rename to coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.cs index 4003e7b0..86117337 100644 --- a/coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.cs +++ b/coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.cs @@ -19,13 +19,6 @@ protected override void Up(MigrationBuilder migrationBuilder) nullable: false, defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); - migrationBuilder.AddColumn( - name: "PreviousTokenId", - schema: "dbo", - table: "Tokens", - type: "int", - nullable: true); - migrationBuilder.AddColumn( name: "Revoked", schema: "dbo", @@ -51,11 +44,6 @@ protected override void Down(MigrationBuilder migrationBuilder) schema: "dbo", table: "Tokens"); - migrationBuilder.DropColumn( - name: "PreviousTokenId", - schema: "dbo", - table: "Tokens"); - migrationBuilder.DropColumn( name: "Revoked", schema: "dbo", diff --git a/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs b/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs index 4b0a942a..0a96efb5 100644 --- a/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs +++ b/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs @@ -309,9 +309,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Expires") .HasColumnType("datetime2"); - b.Property("PreviousTokenId") - .HasColumnType("int"); - b.Property("Revoked") .HasColumnType("bit"); diff --git a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs index 31015704..7b0b7e5c 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs @@ -320,11 +320,8 @@ public async Task SendMagicLinkEmail(string email, LoginType loginType) public async Task LoginByMagicLink(string token) { // Validate token in DB - var foundToken = await GetTokenByMagicLink(token); - if (foundToken.Revoked) - { - throw new ApiException("Token already used", 401); - } + var foundToken = await _tokenServiceV2.GetValidTokenByHashAsync(token); + // Invalidate token in DB foundToken.Revoked = true; await _context.SaveChangesAsync(); @@ -347,12 +344,8 @@ public async Task LoginByMagicLink(string token) public async Task RefreshToken(string token) { - var foundToken = await GetRefreshToken(token); - if (foundToken.Revoked) - { - await InvalidateTokenChain(foundToken.Id); - throw new ApiException("Token already used", 401); - } + var foundToken = await _tokenServiceV2.GetValidTokenByHashAsync(token); + // Invalidate token in DB foundToken.Revoked = true; await _context.SaveChangesAsync(); @@ -372,42 +365,5 @@ public async Task RefreshToken(string token) return new UserLoginResponse() { Jwt = jwt, RefreshToken = refreshToken }; } - - private async Task GetRefreshToken(string token) - { - var foundToken = await _context.Tokens - .Include(t => t.User) - .FirstOrDefaultAsync(t => t.TokenHash == token); - if (foundToken?.User == null) - { - throw new ApiException("Invalid token", 401); - } - - return foundToken; - } - - private async Task GetTokenByMagicLink(string token) - { - var foundToken = await _context.Tokens - .Include(t => t.User) - .FirstOrDefaultAsync(t => t.TokenHash == token); - if (foundToken?.User == null) - { - throw new ApiException("Invalid token", 401); - } - - return foundToken; - } - - private async Task InvalidateTokenChain(int tokenId) - { - // todo: invalidate all from user instead of recursion - var newerToken = _context.Tokens.FirstOrDefault(t => t.PreviousTokenId == tokenId); - if (newerToken != null) - { - newerToken.Revoked = true; - await InvalidateTokenChain(newerToken.Id); - } - } } } diff --git a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs index be91cb0a..dec5f858 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs @@ -8,5 +8,6 @@ public interface ITokenService string GenerateMagicLink(User user); Task GenerateRefreshTokenAsync(User user); Task ValidateTokenAsync(string token); + Task GetValidTokenByHashAsync(string tokenHash); } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs index b9e674d1..23ff00a7 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -32,7 +32,7 @@ public string GenerateMagicLink(User user) public async Task GenerateRefreshTokenAsync(User user) { var refreshToken = Guid.NewGuid().ToString(); - _context.Tokens.Add(new Token(refreshToken, TokenType.Refresh)); + _context.Tokens.Add(new Token(refreshToken, TokenType.Refresh) { User = user }); await _context.SaveChangesAsync(); return refreshToken; } @@ -42,9 +42,34 @@ public async Task ValidateTokenAsync(string refreshToken) var token = await _context.Tokens.FirstOrDefaultAsync(t => t.TokenHash == refreshToken); if (token.Revoked) { - // TODO: Invalidate chain of tokens + await InvalidateRefreshTokensForUser(token.User); throw new ApiException("Refresh token is already used", 401); } throw new NotImplementedException(); } + + public async Task GetValidTokenByHashAsync(string tokenHash) + { + var foundToken = await _context.Tokens.Include(t => t.User).FirstOrDefaultAsync(t => t.TokenHash == tokenHash); + if (foundToken == null || foundToken.Revoked || foundToken.Expired()) + { + await InvalidateRefreshTokensForUser(foundToken?.User); + throw new ApiException("Invalid token", 401); + } + return foundToken; + } + + private async Task InvalidateRefreshTokensForUser(User user) + { + if (user is null) return; + + var tokens = _context.Tokens.Where(t => t.UserId == user.Id && t.Type == TokenType.Refresh); + + _context.Tokens.UpdateRange(tokens); + foreach (var token in tokens) + { + token.Revoked = true; + } + await _context.SaveChangesAsync(); + } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Models/Entities/Token.cs b/coffeecard/CoffeeCard.Models/Entities/Token.cs index 08f627ed..15878c44 100644 --- a/coffeecard/CoffeeCard.Models/Entities/Token.cs +++ b/coffeecard/CoffeeCard.Models/Entities/Token.cs @@ -20,8 +20,6 @@ public class Token(string tokenHash, TokenType type) public bool Revoked { get; set; } = false; - public int? PreviousTokenId { get; set; } - public override bool Equals(object? obj) { if (obj is Token newToken) return TokenHash.Equals(newToken.TokenHash); @@ -32,5 +30,10 @@ public override int GetHashCode() { return HashCode.Combine(Id, TokenHash, User); } + + public bool Expired() + { + return DateTime.UtcNow > Expires; + } } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index 768ffad4..ba5b6fdc 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -258,12 +258,23 @@ public async Task> AuthToken([FromRoute] string [HttpPost] [AuthorizeRoles(UserGroup.Customer, UserGroup.Barista, UserGroup.Manager, UserGroup.Board)] - [Route("auth/refresh")] - public async Task> Refresh() + [Route("auth/refresh/loginType={loginType}")] + public async Task> Refresh([FromRoute] LoginType loginType, string refreshToken = null) { - var refreshToken = HttpContext.Request.Cookies.FirstOrDefault(c => c.Key == "refreshToken").Value; + switch (loginType) + { + case LoginType.App: + if (refreshToken is null) return NotFound(new MessageResponseDto { Message = "Refresh token required for app refresh." }); + break; + case LoginType.Shifty: + refreshToken = HttpContext.Request.Cookies.FirstOrDefault(c => c.Key == "refreshToken").Value; + break; + default: + return NotFound(new MessageResponseDto { Message = "Cannot determine application to login." }); + } + var token = await _accountService.RefreshToken(refreshToken); - return Ok(token); + return Tokenize(loginType, token); } private ActionResult Tokenize(LoginType loginType, UserLoginResponse token)