WIP: feat(security): implement refresh tokens #49
@@ -186,12 +186,9 @@ class TokenServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void revokedFamily_throwsNotAuthenticated() {
|
void revokedFamily_throwsNotAuthenticated() {
|
||||||
TokenFamily tokenFamily = new TokenFamily();
|
TokenFamily tokenFamily = activeFamily();
|
||||||
tokenFamily.setId(1L);
|
|
||||||
tokenFamily.setRevokedAt(Instant.now(clock));
|
tokenFamily.setRevokedAt(Instant.now(clock));
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
RefreshToken refreshToken = new RefreshToken();
|
|
||||||
refreshToken.setTokenFamily(tokenFamily);
|
|
||||||
|
|
||||||
clock.advance(Duration.ofMinutes(1));
|
clock.advance(Duration.ofMinutes(1));
|
||||||
|
|
||||||
@@ -208,14 +205,8 @@ class TokenServiceTest {
|
|||||||
// Happy Path
|
// Happy Path
|
||||||
@Test
|
@Test
|
||||||
void unusedToken_returnsUserIdAndRefreshToken() {
|
void unusedToken_returnsUserIdAndRefreshToken() {
|
||||||
TokenFamily tokenFamily = new TokenFamily();
|
TokenFamily tokenFamily = activeFamily();
|
||||||
tokenFamily.setId(1L);
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
|
||||||
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
RefreshToken refreshToken = new RefreshToken();
|
|
||||||
refreshToken.setTokenFamily(tokenFamily);
|
|
||||||
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
when(refreshTokenRepository.findByHash(any()))
|
when(refreshTokenRepository.findByHash(any()))
|
||||||
.thenReturn(Optional.of(refreshToken));
|
.thenReturn(Optional.of(refreshToken));
|
||||||
@@ -228,14 +219,8 @@ class TokenServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unsedToken_savesRefreshToken() {
|
void unsedToken_savesRefreshToken() {
|
||||||
TokenFamily tokenFamily = new TokenFamily();
|
TokenFamily tokenFamily = activeFamily();
|
||||||
tokenFamily.setId(1L);
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
|
||||||
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
RefreshToken refreshToken = new RefreshToken();
|
|
||||||
refreshToken.setTokenFamily(tokenFamily);
|
|
||||||
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
when(refreshTokenRepository.findByHash(any()))
|
when(refreshTokenRepository.findByHash(any()))
|
||||||
.thenReturn(Optional.of(refreshToken));
|
.thenReturn(Optional.of(refreshToken));
|
||||||
@@ -257,14 +242,8 @@ class TokenServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void ununsedToken_savesOldTokenWithUsedAt() {
|
void ununsedToken_savesOldTokenWithUsedAt() {
|
||||||
TokenFamily tokenFamily = new TokenFamily();
|
TokenFamily tokenFamily = activeFamily();
|
||||||
tokenFamily.setId(1L);
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
|
||||||
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
RefreshToken refreshToken = new RefreshToken();
|
|
||||||
refreshToken.setTokenFamily(tokenFamily);
|
|
||||||
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
when(refreshTokenRepository.findByHash(any()))
|
when(refreshTokenRepository.findByHash(any()))
|
||||||
.thenReturn(Optional.of(refreshToken));
|
.thenReturn(Optional.of(refreshToken));
|
||||||
@@ -283,14 +262,8 @@ class TokenServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unusedToken_doesNotRevokeFamily() {
|
void unusedToken_doesNotRevokeFamily() {
|
||||||
TokenFamily tokenFamily = new TokenFamily();
|
TokenFamily tokenFamily = activeFamily();
|
||||||
tokenFamily.setId(1L);
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
|
||||||
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
RefreshToken refreshToken = new RefreshToken();
|
|
||||||
refreshToken.setTokenFamily(tokenFamily);
|
|
||||||
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
when(refreshTokenRepository.findByHash(any()))
|
when(refreshTokenRepository.findByHash(any()))
|
||||||
.thenReturn(Optional.of(refreshToken));
|
.thenReturn(Optional.of(refreshToken));
|
||||||
@@ -307,15 +280,9 @@ class TokenServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unusedToken_newTokenIsDifferentFromOldToken() {
|
void unusedToken_newTokenIsDifferentFromOldToken() {
|
||||||
TokenFamily tokenFamily = new TokenFamily();
|
TokenFamily tokenFamily = activeFamily();
|
||||||
tokenFamily.setId(1L);
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
|
||||||
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
RefreshToken refreshToken = new RefreshToken();
|
|
||||||
refreshToken.setTokenHash(rawToken.getBytes());
|
refreshToken.setTokenHash(rawToken.getBytes());
|
||||||
refreshToken.setTokenFamily(tokenFamily);
|
|
||||||
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
when(refreshTokenRepository.findByHash(any()))
|
when(refreshTokenRepository.findByHash(any()))
|
||||||
.thenReturn(Optional.of(refreshToken));
|
.thenReturn(Optional.of(refreshToken));
|
||||||
@@ -329,15 +296,9 @@ class TokenServiceTest {
|
|||||||
void usedWithinGrace_returnsNewToken() {
|
void usedWithinGrace_returnsNewToken() {
|
||||||
Instant usedAt = Instant.now(clock);
|
Instant usedAt = Instant.now(clock);
|
||||||
|
|
||||||
TokenFamily tokenFamily = new TokenFamily();
|
TokenFamily tokenFamily = activeFamily();
|
||||||
tokenFamily.setId(1L);
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
|
||||||
tokenFamily.setExpiresAt(usedAt.plusSeconds(10));
|
|
||||||
|
|
||||||
RefreshToken refreshToken = new RefreshToken();
|
|
||||||
refreshToken.setTokenHash(rawToken.getBytes());
|
refreshToken.setTokenHash(rawToken.getBytes());
|
||||||
refreshToken.setTokenFamily(tokenFamily);
|
|
||||||
refreshToken.setExpiresAt(usedAt.plusSeconds(10));
|
|
||||||
refreshToken.setUsedAt(usedAt);
|
refreshToken.setUsedAt(usedAt);
|
||||||
|
|
||||||
when(refreshTokenRepository.findByHash(any()))
|
when(refreshTokenRepository.findByHash(any()))
|
||||||
@@ -362,5 +323,215 @@ class TokenServiceTest {
|
|||||||
assertSame(tokenFamily, newToken.getTokenFamily());
|
assertSame(tokenFamily, newToken.getTokenFamily());
|
||||||
assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash());
|
assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usedAtExactGraceBoundary_returnsNewToken() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(authProperties.expiration().gracePeriod());
|
||||||
|
|
||||||
|
TokenService.Rotation rotation = tokenService.rotate(rawToken);
|
||||||
|
|
||||||
|
assertEquals(1L, rotation.userId());
|
||||||
|
assertNull(tokenFamily.getRevokedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse after grace period
|
||||||
|
@Test
|
||||||
|
void usedAfterGrace_throwsNotAuthenticated() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(6));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usedAfterGrace_revokesFamily() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(6));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
verify(tokenFamilyRepository).save(familyCaptor.capture());
|
||||||
|
assertSame(tokenFamily, familyCaptor.getValue());
|
||||||
|
assertEquals(clock.instant(), tokenFamily.getRevokedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usedAfterGrace_doesNotIssueNewToken() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(6));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
verify(refreshTokenRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usedAfterGrace_subsequentRotateRejectedWithoutResaving() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(6));
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
verify(tokenFamilyRepository, times(1)).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiry
|
||||||
|
@Test
|
||||||
|
void expiredToken_throwsNotAuthenticated() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setExpiresAt(clock.instant().minusSeconds(1));
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiredFamily_throwsNotAuthenticated() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
tokenFamily.setExpiresAt(clock.instant().minusSeconds(1));
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiredToken_doesNotSaveOrRevoke() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(11));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
verify(refreshTokenRepository, never()).save(any());
|
||||||
|
verify(tokenFamilyRepository, never()).save(any());
|
||||||
|
assertNull(refreshToken.getUsedAt());
|
||||||
|
assertNull(tokenFamily.getRevokedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenAtExactExpiry_returnsNewToken() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(10));
|
||||||
|
|
||||||
|
TokenService.Rotation rotation = tokenService.rotate(rawToken);
|
||||||
|
|
||||||
|
assertEquals(1L, rotation.userId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordering
|
||||||
|
@Test
|
||||||
|
void revokedFamilyAndUsedAfterGrace_doesNotRevokeAgain() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
Instant revokedAt = clock.instant();
|
||||||
|
tokenFamily.setRevokedAt(revokedAt);
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(6));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
assertEquals(revokedAt, tokenFamily.getRevokedAt());
|
||||||
|
verify(tokenFamilyRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokedAndExpiredFamily_throwsNotAuthenticated() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
tokenFamily.setRevokedAt(clock.instant());
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(11));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiredAndUsedAfterGrace_revokesFamily() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(11));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
assertEquals(clock.instant(), tokenFamily.getRevokedAt());
|
||||||
|
verify(tokenFamilyRepository).save(tokenFamily);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiredWithinGrace_doesNotRevokeFamily() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setExpiresAt(clock.instant().plusSeconds(2));
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(3));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
assertNull(tokenFamily.getRevokedAt());
|
||||||
|
verify(tokenFamilyRepository, never()).save(any());
|
||||||
|
verify(refreshTokenRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownToken_doesNotTouchRepositoriesFurther() {
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
verify(refreshTokenRepository, never()).save(any());
|
||||||
|
verifyNoInteractions(tokenFamilyRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenFamily activeFamily() {
|
||||||
|
TokenFamily tokenFamily = new TokenFamily();
|
||||||
|
tokenFamily.setId(1L);
|
||||||
|
tokenFamily.setUser(createUser(1L, "user", "user"));
|
||||||
|
tokenFamily.setExpiresAt(clock.instant().plusSeconds(10));
|
||||||
|
return tokenFamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RefreshToken tokenIn(TokenFamily tokenFamily) {
|
||||||
|
RefreshToken refreshToken = new RefreshToken();
|
||||||
|
refreshToken.setTokenFamily(tokenFamily);
|
||||||
|
refreshToken.setExpiresAt(clock.instant().plusSeconds(10));
|
||||||
|
return refreshToken;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user