diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs index d330076..0d2ae02 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs @@ -427,7 +427,7 @@ public async Task RefreshToken_ReturnsExpiredError_WhenTokenIsExpired() Id = Guid.NewGuid(), UserId = alice.Id, Token = hashedToken, - ExpiryDateUtc = DateTime.UtcNow.AddDays(-1) + Expires = DateTime.UtcNow.AddDays(-1) }; DbContext.RefreshTokens.Add(expiredToken); await DbContext.SaveChangesAsync(); @@ -447,7 +447,96 @@ public async Task RefreshToken_ReturnsExpiredError_WhenTokenIsExpired() } [Fact] - public async Task LoginUser_RemovesOldRefreshTokens_OnReLogin() + public async Task RefreshToken_ReturnsConflictError_WhenRotationRaces() + { + using var client1 = CreateClient(); + using var client2 = CreateClient(); + + await client1.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "alice@example.com", password = "Password123!" } }); + + var sharedRefreshToken = client1.GetRefreshTokenCookie(); + Assert.NotNull(sharedRefreshToken); + client1.SetRefreshTokenCookie(sharedRefreshToken); + client2.SetRefreshTokenCookie(sharedRefreshToken); + + var task1 = client1.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.RefreshToken, new { }); + var task2 = client2.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.RefreshToken, new { }); + + var results = await Task.WhenAll(task1, task2); + + var successResponse = results.FirstOrDefault(r => !r.HasErrors); + var failureResponse = results.FirstOrDefault(r => r.HasErrors); + + Assert.NotNull(successResponse); + Assert.NotNull(successResponse.Data?.RefreshToken?.String); + Assert.NotNull(failureResponse); + Assert.NotNull(failureResponse.Errors); + + var error = failureResponse.Errors!.First(); + var expectedError = UserErrors.RefreshTokenConflict(); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + Assert.Equal(expectedError.Message, error.Message); + + var alice = await DbContext.Users.FirstAsync(u => u.Email == "alice@example.com"); + var activeTokenCount = await DbContext.RefreshTokens + .AsNoTracking() + .CountAsync(r => r.UserId == alice.Id && r.Revoked == null); + Assert.Equal(1, activeTokenCount); + } + + [Fact] + public async Task RefreshToken_RevokesAllActiveSessions_WhenRevokedTokenIsReused() + { + var alice = await DbContext.Users.FirstAsync(u => u.Email == "alice@example.com"); + + var reusedRawToken = "already-rotated-token-value"; + var reusedHashedToken = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(reusedRawToken))).ToLowerInvariant(); + var revokedToken = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = alice.Id, + Token = reusedHashedToken, + Created = DateTime.UtcNow.AddMinutes(-10), + Expires = DateTime.UtcNow.AddDays(6), + Revoked = DateTime.UtcNow.AddMinutes(-5) + }; + + var otherActiveToken = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = alice.Id, + Token = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("legit-active-session"))).ToLowerInvariant(), + Created = DateTime.UtcNow.AddMinutes(-5), + Expires = DateTime.UtcNow.AddDays(7) + }; + + DbContext.RefreshTokens.AddRange(revokedToken, otherActiveToken); + await DbContext.SaveChangesAsync(); + + using var client = CreateClient(); + client.SetRefreshTokenCookie(reusedRawToken); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.RefreshToken, new { }); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = UserErrors.RefreshTokenReused(); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + Assert.Equal(expectedError.Message, error.Message); + + var activeTokenCount = await DbContext.RefreshTokens + .AsNoTracking() + .CountAsync(r => r.UserId == alice.Id && r.Revoked == null); + Assert.Equal(0, activeTokenCount); + } + + [Fact] + public async Task LoginUser_KeepsOtherSessions_OnReLogin() { using var client = CreateClient(); var loginVars = new { input = new { email = "alice@example.com", password = "Password123!" } }; @@ -459,8 +548,148 @@ await client.ExecuteMutationAsync( Shared.MutationExamples.Mutations.Users.LoginUser, loginVars); var alice = await DbContext.Users.FirstAsync(u => u.Email == "alice@example.com"); - var tokenCount = await DbContext.RefreshTokens.CountAsync(r => r.UserId == alice.Id); - Assert.Equal(1, tokenCount); + var activeTokenCount = await DbContext.RefreshTokens + .AsNoTracking() + .CountAsync(r => r.UserId == alice.Id && r.Revoked == null); + Assert.Equal(2, activeTokenCount); + } + + [Fact] + public async Task LogoutUser_RevokesTokenAndClearsCookie_WhenAuthenticated() + { + using var client = CreateClient(); + + var loginResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "alice@example.com", password = "Password123!" } }); + client.SetAuthToken(loginResponse.Data!.LoginUser!.String!); + + var rawRefreshToken = client.GetRefreshTokenCookie(); + Assert.NotNull(rawRefreshToken); + client.SetRefreshTokenCookie(rawRefreshToken); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LogoutUser, new { }); + + Assert.False(response.HasErrors); + Assert.True(response.Data?.LogoutUser?.Boolean); + + var alice = await DbContext.Users.FirstAsync(u => u.Email == "alice@example.com"); + var storedToken = await DbContext.RefreshTokens + .AsNoTracking() + .SingleAsync(r => r.UserId == alice.Id); + Assert.NotNull(storedToken.Revoked); + Assert.False(storedToken.IsActive); + + var cookieAfterLogout = client.GetRefreshTokenCookie(); + Assert.NotNull(cookieAfterLogout); + Assert.NotEqual(rawRefreshToken, cookieAfterLogout); + } + + [Fact] + public async Task LogoutUser_ReturnsInvalidError_WhenCookieIsMissing() + { + using var client = CreateClient(); + + var loginResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "alice@example.com", password = "Password123!" } }); + client.SetAuthToken(loginResponse.Data!.LoginUser!.String!); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LogoutUser, new { }); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = UserErrors.RefreshTokenInvalid(); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + Assert.Equal(expectedError.Message, error.Message); + } + + [Fact] + public async Task LogoutUser_ReturnsInvalidError_WhenTokenAlreadyRevoked() + { + var alice = await DbContext.Users.FirstAsync(u => u.Email == "alice@example.com"); + + var rawToken = "already-revoked-token-value"; + var hashedToken = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken))).ToLowerInvariant(); + var revokedToken = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = alice.Id, + Token = hashedToken, + Created = DateTime.UtcNow.AddMinutes(-10), + Expires = DateTime.UtcNow.AddDays(6), + Revoked = DateTime.UtcNow.AddMinutes(-5) + }; + DbContext.RefreshTokens.Add(revokedToken); + await DbContext.SaveChangesAsync(); + + using var client = CreateClient(); + var loginResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "alice@example.com", password = "Password123!" } }); + client.SetAuthToken(loginResponse.Data!.LoginUser!.String!); + client.SetRefreshTokenCookie(rawToken); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LogoutUser, new { }); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = UserErrors.RefreshTokenInvalid(); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + Assert.Equal(expectedError.Message, error.Message); + } + + [Fact] + public async Task LogoutUser_ReturnsError_WhenNotAuthenticated() + { + using var client = CreateClient(); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LogoutUser, new { }); + + Assert.True(response.HasErrors); + Assert.Null(response.Data?.LogoutUser); + } + + [Fact] + public async Task LogoutUser_TreatsConcurrentRevoke_AsIdempotentSuccess() + { + using var client1 = CreateClient(); + using var client2 = CreateClient(); + + var loginResponse = await client1.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "alice@example.com", password = "Password123!" } }); + var authToken = loginResponse.Data!.LoginUser!.String!; + client1.SetAuthToken(authToken); + client2.SetAuthToken(authToken); + + var sharedRefreshToken = client1.GetRefreshTokenCookie(); + Assert.NotNull(sharedRefreshToken); + client1.SetRefreshTokenCookie(sharedRefreshToken); + client2.SetRefreshTokenCookie(sharedRefreshToken); + + var task1 = client1.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LogoutUser, new { }); + var task2 = client2.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LogoutUser, new { }); + + var results = await Task.WhenAll(task1, task2); + + Assert.All(results, r => + { + Assert.False(r.HasErrors); + Assert.True(r.Data?.LogoutUser?.Boolean); + }); + + var alice = await DbContext.Users.FirstAsync(u => u.Email == "alice@example.com"); + var activeTokenCount = await DbContext.RefreshTokens + .AsNoTracking() + .CountAsync(r => r.UserId == alice.Id && r.Revoked == null); + Assert.Equal(0, activeTokenCount); } [Fact] diff --git a/TournamentAPI.Shared/Models/ResponseModels.cs b/TournamentAPI.Shared/Models/ResponseModels.cs index 4f39d17..a401177 100644 --- a/TournamentAPI.Shared/Models/ResponseModels.cs +++ b/TournamentAPI.Shared/Models/ResponseModels.cs @@ -233,6 +233,16 @@ public class RefreshTokenResult public string? String { get; set; } } +public class LogoutResponse +{ + public LogoutUserResult? LogoutUser { get; set; } +} + +public class LogoutUserResult +{ + public bool? Boolean { get; set; } +} + public class MeResponse { public UserNode? Me { get; set; } diff --git a/TournamentAPI.Shared/MutationExamples/UserMutations.cs b/TournamentAPI.Shared/MutationExamples/UserMutations.cs index 25c3abb..e2cadd9 100644 --- a/TournamentAPI.Shared/MutationExamples/UserMutations.cs +++ b/TournamentAPI.Shared/MutationExamples/UserMutations.cs @@ -27,6 +27,14 @@ mutation RefreshToken { } """; + public const string LogoutUser = """ + mutation LogoutUser { + logoutUser { + boolean + } + } + """; + public const string UpdateEmailVisibility = """ mutation UpdateEmailVisibility($input: UpdateEmailVisibilityInput!) { updateEmailVisibility(input: $input) { diff --git a/TournamentAPI/Data/Models/RefreshToken.cs b/TournamentAPI/Data/Models/RefreshToken.cs index b0f79af..e1f5919 100644 --- a/TournamentAPI/Data/Models/RefreshToken.cs +++ b/TournamentAPI/Data/Models/RefreshToken.cs @@ -1,10 +1,20 @@ +using System.ComponentModel.DataAnnotations; + namespace TournamentAPI.Data.Models; public class RefreshToken { public Guid Id { get; set; } public string Token { get; set; } = null!; - public DateTime ExpiryDateUtc { get; set; } + public DateTime Expires { get; set; } + public DateTime Created { get; set; } + public DateTime? Revoked { get; set; } + public string? ReplacedByToken { get; set; } public int UserId { get; set; } public ApplicationUser User { get; set; } = null!; + + [Timestamp] + public byte[] Version { get; set; } = null!; + + public bool IsActive => Revoked is null && DateTime.UtcNow < Expires; } diff --git a/TournamentAPI/Extensions/HttpResponseExtensions.cs b/TournamentAPI/Extensions/HttpResponseExtensions.cs index 65a3dd1..134355d 100644 --- a/TournamentAPI/Extensions/HttpResponseExtensions.cs +++ b/TournamentAPI/Extensions/HttpResponseExtensions.cs @@ -15,4 +15,16 @@ public static void AppendRefreshTokenCookie(this HttpResponse response, string t Expires = expiry }); } + + public static void ClearRefreshTokenCookie(this HttpResponse response) + { + response.Cookies.Delete( + "refreshToken", + new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax + }); + } } diff --git a/TournamentAPI/Users/UserErrorCodes.cs b/TournamentAPI/Users/UserErrorCodes.cs index 3032920..e56685d 100644 --- a/TournamentAPI/Users/UserErrorCodes.cs +++ b/TournamentAPI/Users/UserErrorCodes.cs @@ -7,6 +7,9 @@ public static class UserErrorCodes public const string RegistrationFailed = "User.RegistrationFailed"; public const string RefreshTokenExpired = "User.RefreshTokenExpired"; public const string RefreshTokenInvalid = "User.RefreshTokenInvalid"; + public const string RefreshTokenConflict = "User.RefreshTokenConflict"; + public const string RefreshTokenReused = "User.RefreshTokenReused"; public const string UnableToSetRefreshTokenCookie = "User.UnableToSetRefreshTokenCookie"; + public const string HttpContextUnavailable = "User.HttpContextUnavailable"; public const string AccountLockedOut = "User.AccountLockedOut"; } diff --git a/TournamentAPI/Users/UserErrors.cs b/TournamentAPI/Users/UserErrors.cs index 2b93af6..ebe27a1 100644 --- a/TournamentAPI/Users/UserErrors.cs +++ b/TournamentAPI/Users/UserErrors.cs @@ -34,12 +34,30 @@ public static IError RefreshTokenInvalid() => .SetCode(UserErrorCodes.RefreshTokenInvalid) .Build(); + public static IError RefreshTokenConflict() => + ErrorBuilder.New() + .SetMessage("The refresh token was already used to obtain a new token. Please try refreshing again.") + .SetCode(UserErrorCodes.RefreshTokenConflict) + .Build(); + + public static IError RefreshTokenReused() => + ErrorBuilder.New() + .SetMessage("This refresh token has already been used. All active sessions have been revoked as a precaution.") + .SetCode(UserErrorCodes.RefreshTokenReused) + .Build(); + public static IError UnableToSetRefreshTokenCookie() => ErrorBuilder.New() .SetMessage("Unable to set refresh token cookie.") .SetCode(UserErrorCodes.UnableToSetRefreshTokenCookie) .Build(); + public static IError HttpContextUnavailable() => + ErrorBuilder.New() + .SetMessage("Unable to process the request because no HTTP context is available.") + .SetCode(UserErrorCodes.HttpContextUnavailable) + .Build(); + public static IError AccountLockedOut => ErrorBuilder.New() .SetMessage("The account is locked out due to multiple failed login attempts.") diff --git a/TournamentAPI/Users/UserMutations.cs b/TournamentAPI/Users/UserMutations.cs index c41f7d0..91436b7 100644 --- a/TournamentAPI/Users/UserMutations.cs +++ b/TournamentAPI/Users/UserMutations.cs @@ -56,6 +56,56 @@ public static partial class UserMutations return true; } + [Authorize] + public static async Task LogoutUser( + SignInManager signInManager, + ApplicationDbContext context, + IResolverContext resolverContext, + IHttpContextAccessor httpContextAccessor, + JwtService jwtService + ) + { + await signInManager.SignOutAsync(); + if (httpContextAccessor.HttpContext == null) + { + resolverContext.ReportError(UserErrors.HttpContextUnavailable()); + return null; + } + + var rawCookieToken = httpContextAccessor.HttpContext.Request.Cookies["refreshToken"]; + var hashedCookieToken = jwtService.HashRefreshToken(rawCookieToken ?? string.Empty); + + var existingToken = await context.RefreshTokens + .FirstOrDefaultAsync(r => r.Token == hashedCookieToken); + + if (existingToken is null || !existingToken.IsActive) + { + resolverContext.ReportError(UserErrors.RefreshTokenInvalid()); + return null; + } + + existingToken.Revoked = DateTime.UtcNow; + + try + { + await context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + await context.Entry(existingToken).ReloadAsync(); + + if (existingToken.IsActive) + { + resolverContext.ReportError(UserErrors.RefreshTokenConflict()); + return null; + } + } + + httpContextAccessor.HttpContext.Response.ClearRefreshTokenCookie(); + + return true; + } + public static async Task LoginUser( LoginUserInput input, UserManager userManager, @@ -91,7 +141,8 @@ public static partial class UserMutations Id = Guid.NewGuid(), UserId = user.Id, Token = refreshTokenResult.Hashed, - ExpiryDateUtc = DateTime.UtcNow.AddDays(7), + Created = DateTime.UtcNow, + Expires = DateTime.UtcNow.AddDays(7), }; if (httpContextAccessor.HttpContext == null) @@ -100,14 +151,10 @@ public static partial class UserMutations return null; } - await context.RefreshTokens - .Where(r => r.UserId == user.Id) - .ExecuteDeleteAsync(); - context.RefreshTokens.Add(refreshToken); await context.SaveChangesAsync(); - httpContextAccessor.HttpContext.Response.AppendRefreshTokenCookie(refreshTokenResult.Raw, refreshToken.ExpiryDateUtc); + httpContextAccessor.HttpContext.Response.AppendRefreshTokenCookie(refreshTokenResult.Raw, refreshToken.Expires); return accessToken; } @@ -128,31 +175,76 @@ IHttpContextAccessor httpContextAccessor var rawCookieToken = httpContextAccessor.HttpContext.Request.Cookies["refreshToken"]; var hashedCookieToken = jwtService.HashRefreshToken(rawCookieToken ?? string.Empty); - var refreshTokenEntity = await context.RefreshTokens - .Include(r => r.User) + var existingToken = await context.RefreshTokens .FirstOrDefaultAsync(r => r.Token == hashedCookieToken); - if (refreshTokenEntity is null) + if (existingToken is null) { resolverContext.ReportError(UserErrors.RefreshTokenInvalid()); return null; } - if (refreshTokenEntity.ExpiryDateUtc < DateTime.UtcNow) + if (!existingToken.IsActive) { + if (existingToken.Revoked is not null) + { + await RevokeAllActiveTokensAsync(context, existingToken.UserId); + resolverContext.ReportError(UserErrors.RefreshTokenReused()); + return null; + } + resolverContext.ReportError(UserErrors.RefreshTokenExpired()); return null; } - string accessToken = jwtService.CreateToken(refreshTokenEntity.User); + var user = await context.Users.FirstOrDefaultAsync(u => u.Id == existingToken.UserId); + if (user is null) + { + resolverContext.ReportError(UserErrors.UserNotFound(existingToken.UserId)); + return null; + } + var newRefreshToken = jwtService.CreateRefreshToken(); - refreshTokenEntity.Token = newRefreshToken.Hashed; - refreshTokenEntity.ExpiryDateUtc = DateTime.UtcNow.AddDays(7); + var refreshExpiresAt = DateTime.UtcNow.AddDays(7); - await context.SaveChangesAsync(); + existingToken.Revoked = DateTime.UtcNow; + existingToken.ReplacedByToken = newRefreshToken.Hashed; + + context.RefreshTokens.Add(new RefreshToken + { + Token = newRefreshToken.Hashed, + UserId = user.Id, + Created = DateTime.UtcNow, + Expires = refreshExpiresAt + }); - httpContextAccessor.HttpContext.Response.AppendRefreshTokenCookie(newRefreshToken.Raw, refreshTokenEntity.ExpiryDateUtc); + try + { + await context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + resolverContext.ReportError(UserErrors.RefreshTokenConflict()); + return null; + } + + string accessToken = jwtService.CreateToken(user); + httpContextAccessor.HttpContext.Response.AppendRefreshTokenCookie(newRefreshToken.Raw, refreshExpiresAt); return accessToken; } + + private static async Task RevokeAllActiveTokensAsync(ApplicationDbContext context, int userId) + { + var activeTokens = await context.RefreshTokens + .Where(t => t.UserId == userId && t.Revoked == null) + .ToListAsync(); + + foreach (var token in activeTokens) + { + token.Revoked = DateTime.UtcNow; + } + + await context.SaveChangesAsync(); + } }