Skip to content

Commit

Permalink
Merge pull request #247 from cryptomator/fix-license-handling
Browse files Browse the repository at this point in the history
Fix license handling
  • Loading branch information
SailReal authored Nov 30, 2023
2 parents 665b013 + 913d636 commit a7d865c
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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.");
}
}

Expand All @@ -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();
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -50,22 +51,27 @@ class TestPostConstruct {
@InjectMock
LicenseValidator validator;

@InjectMock
RandomMinuteSleeper randomMinuteSleeper;

MockedStatic<Settings> 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);
}

@AfterEach
public void teardown() {
settingsClass.close();
Arc.container().instance(LicenseHolder.class).destroy();
}

@Test
Expand Down Expand Up @@ -129,22 +135,27 @@ class TestSetter {
@InjectMock
LicenseValidator validator;

@InjectMock
RandomMinuteSleeper randomMinuteSleeper;

MockedStatic<Settings> 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);
}

@AfterEach
public void teardown() {
settingsClass.close();
Arc.container().instance(LicenseHolder.class).destroy();
}

@Test
Expand Down Expand Up @@ -200,22 +211,27 @@ class TestRefreshLicense {
@InjectMock
LicenseValidator validator;

@InjectMock
RandomMinuteSleeper randomMinuteSleeper;

MockedStatic<Settings> 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);
}

@AfterEach
public void teardown() {
settingsClass.close();
Arc.container().instance(LicenseHolder.class).destroy();
}

@Test
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -385,6 +401,9 @@ class LicenseHolderInitPropsTest {
@InjectMock
LicenseValidator validator;

@InjectMock
RandomMinuteSleeper randomMinuteSleeper;

MockedStatic<Settings> settingsClass;

public static class ValidInitPropsInstanceTestProfile implements QuarkusTestProfile {
Expand All @@ -395,19 +414,21 @@ public Map<String, String> 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);
}

@AfterEach
public void teardown() {
settingsClass.close();
Arc.container().instance(LicenseHolder.class).destroy();
}

@Test
Expand All @@ -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());
}

Expand All @@ -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());
}

Expand All @@ -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());
}

Expand All @@ -472,17 +504,18 @@ 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);

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("token3000", "3000");
Mockito.verify(validator, Mockito.times(2)).validate("token3000", "42");
Mockito.verify(session, Mockito.never()).persist(Mockito.any());
Assertions.assertEquals(decodedJWT, holder.get());
}

Expand All @@ -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());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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() {
Expand Down

0 comments on commit a7d865c

Please sign in to comment.