WIP: feat(security): implement refresh tokens #49

Closed
dylan wants to merge 25 commits from feat/add-refresh-tokens into master
Showing only changes of commit 0bc6b3e8db - Show all commits

View File

@@ -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;
}
} }
} }