diff --git a/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java b/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java index 7d51b4f7..a837e277 100644 --- a/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java +++ b/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java @@ -20,7 +20,6 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Instant; -import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -44,6 +43,9 @@ public class LicenseHolder { @Inject LicenseValidator licenseValidator; + @Inject + RandomMinuteSleeper randomMinuteSleeper; + private static final Logger LOG = Logger.getLogger(LicenseHolder.class); private DecodedJWT license; @@ -54,19 +56,34 @@ public class LicenseHolder { void init() { var settings = Settings.get(); if (settings.licenseKey != null) { - applyLicense(settings.hubId, settings.licenseKey, settings); + validateLicense(settings.licenseKey, settings.hubId); } else if (initialId.isPresent() && initialLicense.isPresent()) { - applyLicense(initialId.get(), initialLicense.get(), settings); + applyInitialHubIdAndLicense(initialId.get(), initialLicense.get()); } } - private void applyLicense(String hubId, String licenseKey, Settings settings) { + @Transactional + void validateLicense(String licenseKey, String hubId) { try { this.license = licenseValidator.validate(licenseKey, hubId); } catch (JWTVerificationException e) { LOG.warn("Provided license is invalid. Deleting entry. Please add the license over the REST API again."); + var settings = Settings.get(); settings.licenseKey = null; - settings.persist(); + settings.persistAndFlush(); + } + } + + @Transactional + void applyInitialHubIdAndLicense(String initialId, String initialLicense) { + try { + this.license = licenseValidator.validate(initialLicense, initialId); + var settings = Settings.get(); + settings.licenseKey = initialLicense; + settings.hubId = initialId; + settings.persistAndFlush(); + } catch (JWTVerificationException e) { + LOG.warn("Provided initial license is invalid."); } } @@ -83,15 +100,16 @@ public void set(String token) throws JWTVerificationException { var settings = Settings.get(); this.license = licenseValidator.validate(token, settings.hubId); settings.licenseKey = token; - settings.persist(); + settings.persistAndFlush(); } /** - * Attempts to refresh the Hub licence every day at 01:00 UTC if claim refreshURL is present. + * Attempts to refresh the Hub licence every day between 01:00:00 and 02:00:00 AM UTC if claim refreshURL is present. */ - @Scheduled(cron = "0 1 * * * ?", timeZone = "UTC") + @Scheduled(cron = "0 0 1 * * ?", timeZone = "UTC", concurrentExecution = Scheduled.ConcurrentExecution.SKIP) void refreshLicenseScheduler() throws InterruptedException { if (license != null) { + randomMinuteSleeper.sleep(); // add random sleep between [0,59]min to reduce infrastructure load var refreshUrl = licenseValidator.refreshUrl(license.getToken()); if (refreshUrl.isPresent()) { var client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build(); @@ -110,7 +128,8 @@ void refreshLicense(String refreshUrl, String license, HttpClient client) throws var request = HttpRequest.newBuilder() // .uri(URI.create(refreshUrl)) // .headers("Content-Type", "application/x-www-form-urlencoded") // - .POST(HttpRequest.BodyPublishers.ofString(body)) // + .POST(HttpRequest.BodyPublishers.ofString(body)) // + .version(HttpClient.Version.HTTP_1_1) // .build(); try { var response = client.send(request, HttpResponse.BodyHandlers.ofString()); diff --git a/backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java b/backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java index 86525cd4..ff19aba5 100644 --- a/backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java +++ b/backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java @@ -34,8 +34,9 @@ public class LicenseValidator { public LicenseValidator() { var algorithm = Algorithm.ECDSA512(decodePublicKey(LICENSE_PUBLIC_KEY), null); - var leeway = Instant.now().getEpochSecond(); // this will make sure to accept tokens that expired in the past (beginning from 1970) - this.verifier = JWT.require(algorithm).acceptExpiresAt(leeway).build(); + var expiresleeway = Instant.now().getEpochSecond(); // this will make sure to accept tokens that expired in the past (beginning from 1970) + // ignoring issued at will make sure to accept tokens that are issued "in the future" e.g. when the hub time is behind the store time + this.verifier = JWT.require(algorithm).acceptExpiresAt(expiresleeway).ignoreIssuedAt().build(); } private static ECPublicKey decodePublicKey(String pemEncodedPublicKey) { diff --git a/backend/src/main/java/org/cryptomator/hub/license/RandomMinuteSleeper.java b/backend/src/main/java/org/cryptomator/hub/license/RandomMinuteSleeper.java new file mode 100644 index 00000000..b2d14fa9 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/license/RandomMinuteSleeper.java @@ -0,0 +1,17 @@ +package org.cryptomator.hub.license; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.Random; + +@ApplicationScoped +public class RandomMinuteSleeper { + + private static final long MINUTE_IN_MILLIS = 60 * 1000L; + private static final Random RNG = new Random(); + + void sleep() throws InterruptedException { + Thread.sleep(RNG.nextInt(0, 60) * MINUTE_IN_MILLIS); + } + +} diff --git a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceTest.java b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceTest.java index 957996e4..c4a85708 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceTest.java @@ -1,6 +1,7 @@ package org.cryptomator.hub.api; import io.agroal.api.AgroalDataSource; +import io.quarkus.arc.Arc; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTestProfile; import io.quarkus.test.junit.TestProfile; @@ -9,6 +10,7 @@ import io.quarkus.test.security.oidc.OidcSecurity; import io.restassured.RestAssured; import jakarta.inject.Inject; +import org.cryptomator.hub.license.LicenseHolder; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -35,6 +37,7 @@ public class BillingResourceManagedInstanceTest { @BeforeAll public static void beforeAll() { RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + Arc.container().instance(LicenseHolder.class).destroy(); } public static class ManagedInstanceTestProfile implements QuarkusTestProfile { diff --git a/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java b/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java index d2216023..005e3258 100644 --- a/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java +++ b/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java @@ -33,6 +33,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicBoolean; @QuarkusTest public class LicenseHolderTest { @@ -50,14 +51,20 @@ class TestPostConstruct { @InjectMock LicenseValidator validator; + @InjectMock + RandomMinuteSleeper randomMinuteSleeper; + MockedStatic settingsClass; @BeforeEach - public void setup() { + public void setup() throws InterruptedException { Query mockQuery = Mockito.mock(Query.class); Mockito.doNothing().when(session).persist(Mockito.any()); Mockito.when(session.createQuery(Mockito.anyString())).thenReturn(mockQuery); Mockito.when(mockQuery.getSingleResult()).thenReturn(0l); + Mockito.doNothing().when(randomMinuteSleeper).sleep(); + + Arc.container().instance(LicenseHolder.class).destroy(); settingsClass = Mockito.mockStatic(Settings.class); } @@ -65,7 +72,6 @@ public void setup() { @AfterEach public void teardown() { settingsClass.close(); - Arc.container().instance(LicenseHolder.class).destroy(); } @Test @@ -129,14 +135,20 @@ class TestSetter { @InjectMock LicenseValidator validator; + @InjectMock + RandomMinuteSleeper randomMinuteSleeper; + MockedStatic settingsClass; @BeforeEach - public void setup() { + public void setup() throws InterruptedException { Query mockQuery = Mockito.mock(Query.class); Mockito.doNothing().when(session).persist(Mockito.any()); Mockito.when(session.createQuery(Mockito.anyString())).thenReturn(mockQuery); Mockito.when(mockQuery.getSingleResult()).thenReturn(0l); + Mockito.doNothing().when(randomMinuteSleeper).sleep(); + + Arc.container().instance(LicenseHolder.class).destroy(); settingsClass = Mockito.mockStatic(Settings.class); } @@ -144,7 +156,6 @@ public void setup() { @AfterEach public void teardown() { settingsClass.close(); - Arc.container().instance(LicenseHolder.class).destroy(); } @Test @@ -200,14 +211,20 @@ class TestRefreshLicense { @InjectMock LicenseValidator validator; + @InjectMock + RandomMinuteSleeper randomMinuteSleeper; + MockedStatic settingsClass; @BeforeEach - public void setup() { + public void setup() throws InterruptedException { Query mockQuery = Mockito.mock(Query.class); Mockito.doNothing().when(session).persist(Mockito.any()); Mockito.when(session.createQuery(Mockito.anyString())).thenReturn(mockQuery); Mockito.when(mockQuery.getSingleResult()).thenReturn(0l); + Mockito.doNothing().when(randomMinuteSleeper).sleep(); + + Arc.container().instance(LicenseHolder.class).destroy(); settingsClass = Mockito.mockStatic(Settings.class); } @@ -215,7 +232,6 @@ public void setup() { @AfterEach public void teardown() { settingsClass.close(); - Arc.container().instance(LicenseHolder.class).destroy(); } @Test @@ -336,7 +352,7 @@ public void testNoOpExistingValidTokenExculdingRefreshURL() throws InterruptedEx // init implicitly called due to @PostConstruct which increases the times to verify by 1 // See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information - Mockito.verify(validator, Mockito.times(1)).validate(Mockito.any(), Mockito.any()); + Mockito.verify(validator, Mockito.times(1)).validate("token", "42"); Mockito.verify(session, Mockito.never()).persist(Mockito.any()); Assertions.assertEquals(existingJWT, holder.get()); } @@ -385,6 +401,9 @@ class LicenseHolderInitPropsTest { @InjectMock LicenseValidator validator; + @InjectMock + RandomMinuteSleeper randomMinuteSleeper; + MockedStatic settingsClass; public static class ValidInitPropsInstanceTestProfile implements QuarkusTestProfile { @@ -395,11 +414,14 @@ public Map getConfigOverrides() { } @BeforeEach - public void setup() { + public void setup() throws InterruptedException { Query mockQuery = Mockito.mock(Query.class); Mockito.doNothing().when(session).persist(Mockito.any()); Mockito.when(session.createQuery(Mockito.anyString())).thenReturn(mockQuery); Mockito.when(mockQuery.getSingleResult()).thenReturn(0l); + Mockito.doNothing().when(randomMinuteSleeper).sleep(); + + Arc.container().instance(LicenseHolder.class).destroy(); settingsClass = Mockito.mockStatic(Settings.class); } @@ -407,7 +429,6 @@ public void setup() { @AfterEach public void teardown() { settingsClass.close(); - Arc.container().instance(LicenseHolder.class).destroy(); } @Test @@ -419,11 +440,21 @@ public void testValidInitTokenSet() { settingsMock.hubId = "42"; settingsClass.when(Settings::get).thenReturn(settingsMock); + var newLicensePersisted = new AtomicBoolean(false); + Mockito.doAnswer(invocation -> { + Settings settings = invocation.getArgument(0); + if (settings.hubId.equals("42") && settings.licenseKey.equals("token")) { + newLicensePersisted.set(true); + } + return null; + }).when(session).persist(Mockito.any()); + holder.init(); // init implicitly called due to @PostConstruct which increases the times to verify by 1 // See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information Mockito.verify(validator, Mockito.times(2)).validate("token", "42"); + Assertions.assertTrue(newLicensePersisted.get()); Assertions.assertEquals(decodedJWT, holder.get()); } @@ -442,7 +473,7 @@ public void testInitTokenOnFailedValidationNotSet() { // init implicitly called due to @PostConstruct which increases the times to verify by 1 // See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information Mockito.verify(validator, Mockito.times(2)).validate("token", "42"); - Mockito.verify(session, Mockito.times(2)).persist(Mockito.eq(settingsMock)); + Mockito.verify(session, Mockito.never()).persist(Mockito.eq(settingsMock)); Assertions.assertNull(holder.get()); } @@ -461,6 +492,7 @@ public void testValidDBTokenIgnoresValidInitToken() { // init implicitly called due to @PostConstruct which increases the times to verify by 1 // See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information Mockito.verify(validator, Mockito.times(2)).validate("token3000", "3000"); + Mockito.verify(session, Mockito.never()).persist(Mockito.any()); Assertions.assertEquals(decodedJWT, holder.get()); } @@ -472,9 +504,9 @@ public void testValidDBTokenIgnoresInvalidInitToken() { }); var decodedJWT = Mockito.mock(DecodedJWT.class); - Mockito.when(validator.validate("token3000", "3000")).thenReturn(decodedJWT); + Mockito.when(validator.validate("token3000", "42")).thenReturn(decodedJWT); Settings settingsMock = new Settings(); - settingsMock.hubId = "3000"; + settingsMock.hubId = "42"; settingsMock.licenseKey = "token3000"; settingsClass.when(Settings::get).thenReturn(settingsMock); @@ -482,7 +514,8 @@ public void testValidDBTokenIgnoresInvalidInitToken() { // init implicitly called due to @PostConstruct which increases the times to verify by 1 // See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information - Mockito.verify(validator, Mockito.times(2)).validate("token3000", "3000"); + Mockito.verify(validator, Mockito.times(2)).validate("token3000", "42"); + Mockito.verify(session, Mockito.never()).persist(Mockito.any()); Assertions.assertEquals(decodedJWT, holder.get()); } @@ -496,14 +529,19 @@ public void testSetValidToken() { initSettingsMock.hubId = "42"; settingsClass.when(Settings::get).thenReturn(initSettingsMock); - Settings persistingSettingsMock = new Settings(); - persistingSettingsMock.hubId = "42"; - persistingSettingsMock.licenseKey = "token3000"; + var newLicensePersisted = new AtomicBoolean(false); + Mockito.doAnswer(invocation -> { + Settings settings = invocation.getArgument(0); + if (settings.hubId.equals("42") && settings.licenseKey.equals("token3000")) { + newLicensePersisted.set(true); + } + return null; + }).when(session).persist(Mockito.any()); holder.set("token3000"); Mockito.verify(validator, Mockito.times(1)).validate("token3000", "42"); - Mockito.verify(session, Mockito.times(1)).persist(Mockito.eq(persistingSettingsMock)); + Assertions.assertTrue(newLicensePersisted.get()); Assertions.assertEquals(decodedJWT, holder.get()); } diff --git a/backend/src/test/java/org/cryptomator/hub/license/LicenseValidatorTest.java b/backend/src/test/java/org/cryptomator/hub/license/LicenseValidatorTest.java index f5a0cc5d..f409fab8 100644 --- a/backend/src/test/java/org/cryptomator/hub/license/LicenseValidatorTest.java +++ b/backend/src/test/java/org/cryptomator/hub/license/LicenseValidatorTest.java @@ -13,6 +13,7 @@ public class LicenseValidatorTest { private static final String VALID_TOKEN = "eyJhbGciOiJFUzUxMiJ9.eyJqdGkiOiI0MiIsImlhdCI6MTY0ODA0OTM2MCwiaXNzIjoiU2t5bWF0aWMiLCJhdWQiOiJDcnlwdG9tYXRvciBIdWIiLCJzdWIiOiJodWJAY3J5cHRvbWF0b3Iub3JnIiwic2VhdHMiOjUsImV4cCI6MjUzNDAyMjE0NDAwLCJyZWZyZXNoVXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2h1Yi9zdWJzY3JpcHRpb24_aHViX2lkPTQyIn0.AKyoZ0WQ8xhs8vPymWPHCsc6ch6pZpfxBcrF5QjVLSQVnYz2s5QF3nnkwn4AGR7V14TuhkJMZLUZxMdQAYLyL95sAV2Fu0E4-e1v3IVKlNKtze89eqYvEs6Ak9jWjtecOgPWNWjz2itI4MfJBDmbFtTnehOtqRqUdsDoC9NFik2C7tHm"; private static final String EXPIRED_TOKEN = "eyJhbGciOiJFUzUxMiJ9.eyJqdGkiOiI0MiIsImlhdCI6MTY3NzA4MzI1OSwiaXNzIjoiU2t5bWF0aWMiLCJhdWQiOiJDcnlwdG9tYXRvciBIdWIiLCJzdWIiOiJodWJAY3J5cHRvbWF0b3Iub3JnIiwic2VhdHMiOjUsImV4cCI6OTQ2Njg0ODAwLCJyZWZyZXNoVXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2h1Yi9zdWJzY3JpcHRpb24_aHViX2lkPTQyIn0.APQnWig9ZyT6_xRviPVs3YPTaP1w_YXTpWULgvsUpCGmGQwEmT6nl0x2jNB_jkQi93E7tr9WvipvX5DkXUOYJP3OAJjzPdN7rTX2tnXTKO8irshkcqmvt79v1E4k50YLkwP-1NIwiO_ltp5sezhLbzOVPXRag6mQfc0KvS6PiZTYGYQh"; + private static final String FUTURE_TOKEN = "eyJhbGciOiJFUzUxMiJ9.eyJqdGkiOjQyLCJpYXQiOjE3MDEyNDkzMzEzMSwiaXNzIjoiU2t5bWF0aWMiLCJhdWQiOiJDcnlwdG9tYXRvciBIdWIiLCJzdWIiOiJ0b2JpYXMuaGFnZW1hbm5Ac2t5bWF0aWMuZGUiLCJzZWF0cyI6NSwiZXhwIjoxNzIyMzg0MDAwLCJyZWZyZXNoVXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2h1Yi9zdWJzY3JpcHRpb24_aHViX2lkPTQyIn0.ALd0oyPR3kgntysXp8TZ1LvmHYDiDIGlbmaq52d5wAE1V8MZ1asWvufXgL9YExXvJhFbGCnLu66XgA387rxjrxKeASL_q43ZZUEDxtm8aa7uH2VMOvdM3gXEibSHUzNwO0MRWFbeYWOc8daRNWdxgOcrpX6NcMV7vPZH7yZSEct_cqf5"; private static final String TOKEN_WITH_INVALID_SIGNATURE = "eyJhbGciOiJFUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.AbVUinMiT3J_03je8WTOIl-VdggzvoFgnOsdouAs-DLOtQzau9valrq-S6pETyi9Q18HH-EuwX49Q7m3KC0GuNBJAc9Tksulgsdq8GqwIqZqDKmG7hNmDzaQG1Dpdezn2qzv-otf3ZZe-qNOXUMRImGekfQFIuH_MjD2e8RZyww6lbZk"; private static final String MALFORMED_TOKEN = "hello world"; @@ -47,6 +48,13 @@ public void testValidateExpiredToken() { validator.validate(EXPIRED_TOKEN, "42"); } + @Test + @DisplayName("validate future token") + public void testValidateFutureToken() { + // this should not throw an exception and return a JWT with an issued at in the future + validator.validate(FUTURE_TOKEN, "42"); + } + @Test @DisplayName("validate token with invalid signature") public void testValidateTokenWithInvalidSignature() {