Skip to content

Commit cde9e6c

Browse files
fail startup if license is missing
and remove conditional code
1 parent 66acd10 commit cde9e6c

File tree

17 files changed

+237
-238
lines changed

17 files changed

+237
-238
lines changed

backend/.idea/inspectionProfiles/Project_Default.xml

Lines changed: 0 additions & 10 deletions
This file was deleted.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package org.cryptomator.hub;
2+
3+
import io.quarkus.runtime.Quarkus;
4+
import io.quarkus.runtime.QuarkusApplication;
5+
import io.quarkus.runtime.annotations.QuarkusMain;
6+
import jakarta.inject.Inject;
7+
import org.cryptomator.hub.license.LicenseHolder;
8+
import org.jboss.logging.Logger;
9+
10+
@QuarkusMain
11+
public class Main implements QuarkusApplication {
12+
13+
private static final Logger LOG = Logger.getLogger(Main.class);
14+
15+
@Inject
16+
LicenseHolder license;
17+
18+
@Override
19+
public int run(String... args) throws Exception {
20+
try {
21+
license.ensureLicenseExists();
22+
} catch (RuntimeException e) {
23+
LOG.error("Failed to validate license, shutting down...", e);
24+
return 1;
25+
}
26+
Quarkus.waitForExit();
27+
return 0;
28+
}
29+
30+
}

backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public class AuditLogResource {
6262
@APIResponse(responseCode = "402", description = "Community license used or license expired")
6363
@APIResponse(responseCode = "403", description = "requesting user does not have admin role")
6464
public List<AuditEventDto> getAllEvents(@QueryParam("startDate") Instant startDate, @QueryParam("endDate") Instant endDate, @QueryParam("paginationId") Long paginationId, @QueryParam("order") @DefaultValue("desc") String order, @QueryParam("pageSize") @DefaultValue("20") int pageSize) {
65-
if (!license.isSet() || license.isExpired()) {
65+
if (!license.isSet() || license.isExpired()) { // TODO change to license.getClaim("auditLog") != null
6666
throw new PaymentRequiredException("Community license used or license expired");
6767
}
6868

backend/src/main/java/org/cryptomator/hub/api/BillingResource.java

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,8 @@ public class BillingResource {
4646
public BillingDto get() {
4747
int usedSeats = (int) effectiveVaultAccessRepo.countSeatOccupyingUsers();
4848
boolean isManaged = licenseHolder.isManagedInstance();
49-
return Optional.ofNullable(licenseHolder.get())
50-
.map(jwt -> BillingDto.fromDecodedJwt(jwt, usedSeats, isManaged))
51-
.orElseGet(() -> {
52-
var hubId = settingsRepo.get().getHubId();
53-
return BillingDto.create(hubId, (int) licenseHolder.getSeats(), usedSeats, isManaged);
54-
});
49+
var licenseToken = licenseHolder.get();
50+
return BillingDto.fromDecodedJwt(licenseToken, usedSeats, isManaged);
5551
}
5652

5753
@PUT
@@ -75,10 +71,6 @@ public record BillingDto(@JsonProperty("hubId") String hubId, @JsonProperty("has
7571
@JsonProperty("licensedSeats") Integer licensedSeats, @JsonProperty("usedSeats") Integer usedSeats,
7672
@JsonProperty("issuedAt") Instant issuedAt, @JsonProperty("expiresAt") Instant expiresAt, @JsonProperty("managedInstance") Boolean managedInstance) {
7773

78-
public static BillingDto create(String hubId, int noLicenseSeatCount, int usedSeats, boolean isManaged) {
79-
return new BillingDto(hubId, false, null, noLicenseSeatCount, usedSeats, null, null, isManaged);
80-
}
81-
8274
public static BillingDto fromDecodedJwt(DecodedJWT jwt, int usedSeats, boolean isManaged) {
8375
var id = jwt.getId();
8476
var email = jwt.getSubject();

backend/src/main/java/org/cryptomator/hub/api/LicenseResource.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package org.cryptomator.hub.api;
22

3-
import com.auth0.jwt.interfaces.DecodedJWT;
43
import com.fasterxml.jackson.annotation.JsonProperty;
54
import jakarta.annotation.security.RolesAllowed;
65
import jakarta.inject.Inject;
@@ -14,7 +13,6 @@
1413
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
1514

1615
import java.time.Instant;
17-
import java.util.Optional;
1816

1917
@Path("/license")
2018
public class LicenseResource {
@@ -42,7 +40,7 @@ public record LicenseUserInfoDto(@JsonProperty("licensedSeats") Integer licensed
4240

4341
public static LicenseUserInfoDto create(LicenseHolder licenseHolder, int usedSeats) {
4442
var licensedSeats = (int) licenseHolder.getSeats();
45-
var expiresAt = Optional.ofNullable(licenseHolder.get()).map(DecodedJWT::getExpiresAtAsInstant).orElse(null);
43+
var expiresAt = licenseHolder.get().getExpiresAtAsInstant();
4644
return new LicenseUserInfoDto(licensedSeats, usedSeats, expiresAt);
4745
}
4846

backend/src/main/java/org/cryptomator/hub/api/VaultResource.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ public Response legacyUnlock(@PathParam("vaultId") UUID vaultId, @PathParam("dev
281281
var access = legacyAccessTokenRepo.unlock(vaultId, deviceId, jwt.getSubject());
282282
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.SUCCESS);
283283
var subscriptionStateHeaderName = "Hub-Subscription-State";
284-
var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter
284+
var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter // FIXME: we need to refactor this header
285285
return Response.ok(access.getJwe()).header(subscriptionStateHeaderName, subscriptionStateHeaderValue).build();
286286
} catch (NoResultException e){
287287
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.UNAUTHORIZED);
@@ -322,7 +322,7 @@ public Response unlock(@PathParam("vaultId") UUID vaultId, @QueryParam("evenIfAr
322322
if (access != null) {
323323
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.SUCCESS);
324324
var subscriptionStateHeaderName = "Hub-Subscription-State";
325-
var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter
325+
var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter // FIXME: we need to refactor this header
326326
return Response.ok(access.getVaultKey(), MediaType.TEXT_PLAIN_TYPE).header(subscriptionStateHeaderName, subscriptionStateHeaderValue).build();
327327
} else if (vaultRepo.findById(vaultId) == null) {
328328
throw new NotFoundException("No such vault.");

backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java

Lines changed: 42 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
import com.auth0.jwt.exceptions.JWTVerificationException;
44
import com.auth0.jwt.interfaces.Claim;
55
import com.auth0.jwt.interfaces.DecodedJWT;
6+
import com.cronutils.utils.Preconditions;
67
import io.quarkus.scheduler.Scheduled;
78
import io.quarkus.scheduler.ScheduledExecution;
8-
import jakarta.annotation.PostConstruct;
99
import jakarta.enterprise.context.ApplicationScoped;
1010
import jakarta.inject.Inject;
1111
import jakarta.transaction.Transactional;
12+
import jakarta.validation.constraints.NotNull;
1213
import org.cryptomator.hub.entities.Settings;
1314
import org.eclipse.microprofile.config.inject.ConfigProperty;
1415
import org.jboss.logging.Logger;
@@ -26,8 +27,7 @@
2627
@ApplicationScoped
2728
public class LicenseHolder {
2829

29-
private static final int SELFHOSTED_NOLICENSE_SEATS = 5;
30-
private static final int MANAGED_NOLICENSE_SEATS = 0;
30+
private static final Logger LOG = Logger.getLogger(LicenseHolder.class);
3131

3232
@Inject
3333
@ConfigProperty(name = "hub.managed-instance", defaultValue = "false")
@@ -46,49 +46,65 @@ public class LicenseHolder {
4646

4747
@Inject
4848
RandomMinuteSleeper randomMinuteSleeper;
49+
4950
@Inject
5051
Settings.Repository settingsRepo;
5152

52-
private static final Logger LOG = Logger.getLogger(LicenseHolder.class);
5353
private DecodedJWT license;
5454

5555
/**
56-
* Loads the license from the database or from init props, if present
56+
* Makes sure a valid (but possibly expired) license exists.
57+
* <p>
58+
* Called during {@link org.cryptomator.hub.Main application startup}.
59+
*
60+
* @throws JWTVerificationException if the license is invalid
5761
*/
58-
@PostConstruct
59-
void init() {
62+
@Transactional
63+
public void ensureLicenseExists() throws JWTVerificationException{
6064
var settings = settingsRepo.get();
6165
if (settings.getLicenseKey() != null && settings.getHubId() != null) {
62-
validateOrResetExistingLicense(settings);
66+
validateExistingLicense(settings);
6367
} else if (initialLicenseToken.isPresent() && initialId.isPresent()) {
6468
validateAndApplyInitLicense(settings, initialLicenseToken.get(), initialId.get());
69+
} else {
70+
requestAnonTrialLicense();
6571
}
6672
}
6773

68-
@Transactional
69-
void validateOrResetExistingLicense(Settings settings) {
74+
@Transactional(Transactional.TxType.MANDATORY)
75+
void validateExistingLicense(Settings settings) throws JWTVerificationException {
7076
try {
7177
this.license = licenseValidator.validate(settings.getLicenseKey(), settings.getHubId());
78+
LOG.info("Verified existing license.");
7279
} catch (JWTVerificationException e) {
7380
LOG.warn("License in database is invalid or does not match hubId", e);
7481
LOG.warn("Deleting license entry. Please add the license over the REST API again.");
75-
settings.setLicenseKey(null);
76-
settingsRepo.persistAndFlush(settings);
82+
throw e;
7783
}
7884
}
7985

80-
@Transactional
81-
void validateAndApplyInitLicense(Settings settings, String initialLicenseToken, String initialHubId) {
86+
@Transactional(Transactional.TxType.MANDATORY)
87+
void validateAndApplyInitLicense(Settings settings, String initialLicenseToken, String initialHubId) throws JWTVerificationException {
8288
try {
8389
this.license = licenseValidator.validate(initialLicenseToken, initialHubId);
8490
settings.setLicenseKey(initialLicenseToken);
8591
settings.setHubId(initialHubId);
8692
settingsRepo.persistAndFlush(settings);
93+
LOG.info("Successfully imported license from property hub.initial-license.");
8794
} catch (JWTVerificationException e) {
8895
LOG.warn("Provided initial license is invalid or does not match inital hubId.", e);
96+
throw e;
8997
}
9098
}
9199

100+
101+
@Transactional(Transactional.TxType.MANDATORY)
102+
void requestAnonTrialLicense() {
103+
LOG.info("No license found. Requesting trial license...");
104+
// TODO
105+
throw new UnsupportedOperationException("Not yet implemented");
106+
}
107+
92108
/**
93109
* Parses, verifies and persists the given token as the license in the database.
94110
*
@@ -106,7 +122,7 @@ public void set(String token) throws JWTVerificationException {
106122
/**
107123
* Attempts to refresh the Hub licence every day between 01:00:00 and 02:00:00 AM UTC if claim refreshURL is present.
108124
*/
109-
@Scheduled(cron = "0 0 1 * * ?", timeZone = "UTC", concurrentExecution = Scheduled.ConcurrentExecution.SKIP, skipExecutionIf = LicenseHolder.LicenseRefreshSkipper.class)
125+
@Scheduled(cron = "0 0 1 * * ?", timeZone = "UTC", concurrentExecution = Scheduled.ConcurrentExecution.SKIP)
110126
void refreshLicense() throws InterruptedException {
111127
randomMinuteSleeper.sleep(); // add random sleep between [0,59]min to reduce infrastructure load
112128
var refreshUrlClaim = get().getClaim("refreshUrl");
@@ -115,12 +131,12 @@ void refreshLicense() throws InterruptedException {
115131
var refreshUrl = URI.create(refreshUrlClaim.asString());
116132
var refreshedLicense = requestLicenseRefresh(refreshUrl, get().getToken());
117133
set(refreshedLicense);
118-
} catch (LicenseRefreshFailedException lrfe) {
119-
LOG.errorv("Failed to refresh license token. Request to {0} was answerd with response code {1,number,integer}", refreshUrlClaim, lrfe.statusCode);
134+
} catch (LicenseRefreshFailedException e) {
135+
LOG.errorv("Failed to refresh license token. Request to {0} was answerd with response code {1,number,integer}", refreshUrlClaim, e.statusCode);
120136
} catch (IllegalArgumentException | IOException e) {
121137
LOG.error("Failed to refresh license token", e);
122-
} catch (JWTVerificationException jve) {
123-
LOG.error("Failed to refresh license token. Refreshed token is invalid.", jve);
138+
} catch (JWTVerificationException e) {
139+
LOG.error("Failed to refresh license token. Refreshed token is invalid.", e);
124140
}
125141
}
126142
}
@@ -144,49 +160,37 @@ String requestLicenseRefresh(URI refreshUrl, String licenseToken) throws Interru
144160
}
145161
}
146162

163+
@NotNull
147164
public DecodedJWT get() {
148-
return license;
165+
return Preconditions.checkNotNull(license);
149166
}
150167

151168
/**
152169
* Checks if the license is set.
153170
*
154171
* @return {@code true}, if the license _is not null_. Otherwise false.
155172
*/
173+
@Deprecated // FIXME remove this method!
156174
public boolean isSet() {
157175
return license != null;
158176
}
159177

160178
/**
161179
* Checks if the license is expired.
162180
*
163-
* @return {@code true}, if the license _is not nul and expired_. Otherwise false.
181+
* @return {@code true}, if the license expired, {@code false} otherwise.
164182
*/
165183
public boolean isExpired() {
166-
return Optional.ofNullable(license) //
167-
.map(l -> l.getExpiresAt().toInstant().isBefore(Instant.now())) //
168-
.orElse(false);
184+
return Preconditions.checkNotNull(license).getExpiresAt().toInstant().isBefore(Instant.now());
169185
}
170186

171187
/**
172188
* Gets the number of seats in the license
173189
*
174-
* @return Number of seats of the license, if license is not null. Otherwise {@value SELFHOSTED_NOLICENSE_SEATS}.
190+
* @return Number of seats of the license
175191
*/
176192
public long getSeats() {
177-
return Optional.ofNullable(license) //
178-
.map(l -> l.getClaim("seats")) //
179-
.map(Claim::asLong) //
180-
.orElseGet(this::seatsOnNotExisingLicense);
181-
}
182-
183-
//visible for testing
184-
public long seatsOnNotExisingLicense() {
185-
if (!managedInstance) {
186-
return SELFHOSTED_NOLICENSE_SEATS;
187-
} else {
188-
return MANAGED_NOLICENSE_SEATS;
189-
}
193+
return Preconditions.checkNotNull(license).getClaim("seats").asLong();
190194
}
191195

192196
public boolean isManagedInstance() {
@@ -203,15 +207,4 @@ static class LicenseRefreshFailedException extends RuntimeException {
203207
}
204208
}
205209

206-
@ApplicationScoped
207-
public static class LicenseRefreshSkipper implements Scheduled.SkipPredicate {
208-
209-
@Inject
210-
LicenseHolder licenseHolder;
211-
212-
@Override
213-
public boolean test(ScheduledExecution execution) {
214-
return licenseHolder.license == null;
215-
}
216-
}
217210
}

backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ private static ECPublicKey decodePublicKey(String pemEncodedPublicKey) {
5555
}
5656
}
5757

58+
/**
59+
* Validates the token signature and whether it matches the Hub ID. It does NOT check the expiration date, though.
60+
* @param token JWT
61+
* @param expectedHubId the ID of this Hub instance
62+
* @return the verified token.
63+
* @throws JWTVerificationException If validation fails.
64+
*/
5865
public DecodedJWT validate(String token, String expectedHubId) throws JWTVerificationException {
5966
var jwt = verifier.verify(token);
6067
if (!jwt.getId().equals(expectedHubId)) {

backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceIT.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public static void beforeAll() {
3030

3131
@BeforeEach
3232
public void beforeEach() {
33-
Mockito.doReturn(true).when(licenseHolder).isSet();
33+
Mockito.doReturn(true).when(licenseHolder).isSet(); // TODO
3434
Mockito.doReturn(false).when(licenseHolder).isExpired();
3535
}
3636

backend/src/test/java/org/cryptomator/hub/api/BillingResourceIT.java

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -52,22 +52,7 @@ public class AsAdmin {
5252
private static final String MALFORMED_TOKEN = "hello world";
5353

5454
@Test
55-
@DisplayName("GET /billing returns 200 with empty license self-hosted")
56-
public void testGetEmptySelfHosted() {
57-
Mockito.when(licenseHolder.get()).thenReturn(null);
58-
Mockito.when(licenseHolder.getSeats()).thenReturn(5L);
59-
when().get("/billing")
60-
.then().statusCode(200)
61-
.body("hubId", is("42"))
62-
.body("hasLicense", is(false))
63-
.body("email", nullValue())
64-
.body("licensedSeats", is(5)) //community license
65-
.body("usedSeats", is(2)) //depends on the flyway test data migration
66-
.body("issuedAt", nullValue())
67-
.body("expiresAt", nullValue());
68-
}
69-
70-
@Test
55+
@Order(2)
7156
@DisplayName("PUT /billing/token returns 204 for initial token")
7257
public void testPutInitialToken() {
7358
given().contentType(ContentType.TEXT).body(INITIAL_TOKEN)

0 commit comments

Comments
 (0)