Skip to content

non-null license keys #318

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Draft
wants to merge 4 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions backend/src/main/java/org/cryptomator/hub/Main.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.cryptomator.hub;

import io.quarkus.runtime.Quarkus;
import io.quarkus.runtime.QuarkusApplication;
import io.quarkus.runtime.annotations.QuarkusMain;
import jakarta.inject.Inject;
import org.cryptomator.hub.license.LicenseHolder;
import org.jboss.logging.Logger;

@QuarkusMain
public class Main implements QuarkusApplication {

private static final Logger LOG = Logger.getLogger(Main.class);

@Inject
LicenseHolder license;

@Override
public int run(String... args) throws Exception {
try {
license.ensureLicenseExists();
} catch (RuntimeException e) {
LOG.error("Failed to validate license, shutting down...", e);
return 1;
}
Quarkus.waitForExit();
return 0;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public class AuditLogResource {
@APIResponse(responseCode = "402", description = "Community license used or license expired")
@APIResponse(responseCode = "403", description = "requesting user does not have admin role")
public List<AuditEventDto> getAllEvents(@QueryParam("startDate") Instant startDate, @QueryParam("endDate") Instant endDate, @QueryParam("type") List<String> type, @QueryParam("paginationId") Long paginationId, @QueryParam("order") @DefaultValue("desc") String order, @QueryParam("pageSize") @DefaultValue("20") int pageSize) {
if (!license.isSet() || license.isExpired()) {
if (!license.isSet() || license.isExpired()) { // TODO change to license.getClaim("auditLog") != null
throw new PaymentRequiredException("Community license used or license expired");
}

Expand Down
12 changes: 2 additions & 10 deletions backend/src/main/java/org/cryptomator/hub/api/BillingResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,8 @@ public class BillingResource {
public BillingDto get() {
int usedSeats = (int) effectiveVaultAccessRepo.countSeatOccupyingUsers();
boolean isManaged = licenseHolder.isManagedInstance();
return Optional.ofNullable(licenseHolder.get())
.map(jwt -> BillingDto.fromDecodedJwt(jwt, usedSeats, isManaged))
.orElseGet(() -> {
var hubId = settingsRepo.get().getHubId();
return BillingDto.create(hubId, (int) licenseHolder.getSeats(), usedSeats, isManaged);
});
var licenseToken = licenseHolder.get();
return BillingDto.fromDecodedJwt(licenseToken, usedSeats, isManaged);
}

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

public static BillingDto create(String hubId, int noLicenseSeatCount, int usedSeats, boolean isManaged) {
return new BillingDto(hubId, false, null, noLicenseSeatCount, usedSeats, null, null, isManaged);
}

public static BillingDto fromDecodedJwt(DecodedJWT jwt, int usedSeats, boolean isManaged) {
var id = jwt.getId();
var email = jwt.getSubject();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package org.cryptomator.hub.api;

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

import java.time.Instant;
import java.util.Optional;

@Path("/license")
public class LicenseResource {
Expand Down Expand Up @@ -42,7 +40,7 @@ public record LicenseUserInfoDto(@JsonProperty("licensedSeats") Integer licensed

public static LicenseUserInfoDto create(LicenseHolder licenseHolder, int usedSeats) {
var licensedSeats = (int) licenseHolder.getSeats();
var expiresAt = Optional.ofNullable(licenseHolder.get()).map(DecodedJWT::getExpiresAtAsInstant).orElse(null);
var expiresAt = licenseHolder.get().getExpiresAtAsInstant();
return new LicenseUserInfoDto(licensedSeats, usedSeats, expiresAt);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ public Response legacyUnlock(@PathParam("vaultId") UUID vaultId, @PathParam("dev
var access = legacyAccessTokenRepo.unlock(vaultId, deviceId, jwt.getSubject());
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.SUCCESS, ipAddress, deviceId);
var subscriptionStateHeaderName = "Hub-Subscription-State";
var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter
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
return Response.ok(access.getJwe()).header(subscriptionStateHeaderName, subscriptionStateHeaderValue).build();
} catch (NoResultException e) {
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.UNAUTHORIZED, ipAddress, deviceId);
Expand Down Expand Up @@ -328,7 +328,7 @@ public Response unlock(@PathParam("vaultId") UUID vaultId, @QueryParam("evenIfAr
if (access != null) {
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.SUCCESS, ipAddress, deviceId);
var subscriptionStateHeaderName = "Hub-Subscription-State";
var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter
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
return Response.ok(access.getVaultKey(), MediaType.TEXT_PLAIN_TYPE).header(subscriptionStateHeaderName, subscriptionStateHeaderValue).build();
} else if (vaultRepo.findById(vaultId) == null) {
throw new NotFoundException("No such vault.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.cronutils.utils.Preconditions;
import io.quarkus.scheduler.Scheduled;
import io.quarkus.scheduler.ScheduledExecution;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.constraints.NotNull;
import org.cryptomator.hub.entities.Settings;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
Expand All @@ -26,8 +27,7 @@
@ApplicationScoped
public class LicenseHolder {

private static final int SELFHOSTED_NOLICENSE_SEATS = 5;
private static final int MANAGED_NOLICENSE_SEATS = 0;
private static final Logger LOG = Logger.getLogger(LicenseHolder.class);

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

@Inject
RandomMinuteSleeper randomMinuteSleeper;

@Inject
Settings.Repository settingsRepo;

private static final Logger LOG = Logger.getLogger(LicenseHolder.class);
private DecodedJWT license;

/**
* Loads the license from the database or from init props, if present
* Makes sure a valid (but possibly expired) license exists.
* <p>
* Called during {@link org.cryptomator.hub.Main application startup}.
*
* @throws JWTVerificationException if the license is invalid
*/
@PostConstruct
void init() {
@Transactional
public void ensureLicenseExists() throws JWTVerificationException{
var settings = settingsRepo.get();
if (settings.getLicenseKey() != null && settings.getHubId() != null) {
validateOrResetExistingLicense(settings);
validateExistingLicense(settings);
} else if (initialLicenseToken.isPresent() && initialId.isPresent()) {
validateAndApplyInitLicense(settings, initialLicenseToken.get(), initialId.get());
} else {
requestAnonTrialLicense();
}
}

@Transactional
void validateOrResetExistingLicense(Settings settings) {
@Transactional(Transactional.TxType.MANDATORY)
void validateExistingLicense(Settings settings) throws JWTVerificationException {
try {
this.license = licenseValidator.validate(settings.getLicenseKey(), settings.getHubId());
LOG.info("Verified existing license.");
} catch (JWTVerificationException e) {
LOG.warn("License in database is invalid or does not match hubId", e);
LOG.warn("Deleting license entry. Please add the license over the REST API again.");
settings.setLicenseKey(null);
settingsRepo.persistAndFlush(settings);
throw e;
}
}

@Transactional
void validateAndApplyInitLicense(Settings settings, String initialLicenseToken, String initialHubId) {
@Transactional(Transactional.TxType.MANDATORY)
void validateAndApplyInitLicense(Settings settings, String initialLicenseToken, String initialHubId) throws JWTVerificationException {
try {
this.license = licenseValidator.validate(initialLicenseToken, initialHubId);
settings.setLicenseKey(initialLicenseToken);
settings.setHubId(initialHubId);
settingsRepo.persistAndFlush(settings);
LOG.info("Successfully imported license from property hub.initial-license.");
} catch (JWTVerificationException e) {
LOG.warn("Provided initial license is invalid or does not match inital hubId.", e);
throw e;
}
}


@Transactional(Transactional.TxType.MANDATORY)
void requestAnonTrialLicense() {
LOG.info("No license found. Requesting trial license...");
// TODO
throw new UnsupportedOperationException("Not yet implemented");
}

/**
* Parses, verifies and persists the given token as the license in the database.
*
Expand All @@ -106,7 +122,7 @@ public void set(String token) throws JWTVerificationException {
/**
* 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 0 1 * * ?", timeZone = "UTC", concurrentExecution = Scheduled.ConcurrentExecution.SKIP, skipExecutionIf = LicenseHolder.LicenseRefreshSkipper.class)
@Scheduled(cron = "0 0 1 * * ?", timeZone = "UTC", concurrentExecution = Scheduled.ConcurrentExecution.SKIP)
void refreshLicense() throws InterruptedException {
randomMinuteSleeper.sleep(); // add random sleep between [0,59]min to reduce infrastructure load
var refreshUrlClaim = get().getClaim("refreshUrl");
Expand All @@ -115,12 +131,12 @@ void refreshLicense() throws InterruptedException {
var refreshUrl = URI.create(refreshUrlClaim.asString());
var refreshedLicense = requestLicenseRefresh(refreshUrl, get().getToken());
set(refreshedLicense);
} catch (LicenseRefreshFailedException lrfe) {
LOG.errorv("Failed to refresh license token. Request to {0} was answerd with response code {1,number,integer}", refreshUrlClaim, lrfe.statusCode);
} catch (LicenseRefreshFailedException e) {
LOG.errorv("Failed to refresh license token. Request to {0} was answerd with response code {1,number,integer}", refreshUrlClaim, e.statusCode);
} catch (IllegalArgumentException | IOException e) {
LOG.error("Failed to refresh license token", e);
} catch (JWTVerificationException jve) {
LOG.error("Failed to refresh license token. Refreshed token is invalid.", jve);
} catch (JWTVerificationException e) {
LOG.error("Failed to refresh license token. Refreshed token is invalid.", e);
}
}
}
Expand All @@ -144,49 +160,37 @@ String requestLicenseRefresh(URI refreshUrl, String licenseToken) throws Interru
}
}

@NotNull
public DecodedJWT get() {
return license;
return Preconditions.checkNotNull(license);
}

/**
* Checks if the license is set.
*
* @return {@code true}, if the license _is not null_. Otherwise false.
*/
@Deprecated // FIXME remove this method!
public boolean isSet() {
return license != null;
}

/**
* Checks if the license is expired.
*
* @return {@code true}, if the license _is not nul and expired_. Otherwise false.
* @return {@code true}, if the license expired, {@code false} otherwise.
*/
public boolean isExpired() {
return Optional.ofNullable(license) //
.map(l -> l.getExpiresAt().toInstant().isBefore(Instant.now())) //
.orElse(false);
return Preconditions.checkNotNull(license).getExpiresAt().toInstant().isBefore(Instant.now());
}

/**
* Gets the number of seats in the license
*
* @return Number of seats of the license, if license is not null. Otherwise {@value SELFHOSTED_NOLICENSE_SEATS}.
* @return Number of seats of the license
*/
public long getSeats() {
return Optional.ofNullable(license) //
.map(l -> l.getClaim("seats")) //
.map(Claim::asLong) //
.orElseGet(this::seatsOnNotExisingLicense);
}

//visible for testing
public long seatsOnNotExisingLicense() {
if (!managedInstance) {
return SELFHOSTED_NOLICENSE_SEATS;
} else {
return MANAGED_NOLICENSE_SEATS;
}
return Preconditions.checkNotNull(license).getClaim("seats").asLong();
}

public boolean isManagedInstance() {
Expand All @@ -203,15 +207,4 @@ static class LicenseRefreshFailedException extends RuntimeException {
}
}

@ApplicationScoped
public static class LicenseRefreshSkipper implements Scheduled.SkipPredicate {

@Inject
LicenseHolder licenseHolder;

@Override
public boolean test(ScheduledExecution execution) {
return licenseHolder.license == null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ private static ECPublicKey decodePublicKey(String pemEncodedPublicKey) {
}
}

/**
* Validates the token signature and whether it matches the Hub ID. It does NOT check the expiration date, though.
* @param token JWT
* @param expectedHubId the ID of this Hub instance
* @return the verified token.
* @throws JWTVerificationException If validation fails.
*/
public DecodedJWT validate(String token, String expectedHubId) throws JWTVerificationException {
var jwt = verifier.verify(token);
if (!jwt.getId().equals(expectedHubId)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public static void beforeAll() {

@BeforeEach
public void beforeEach() {
Mockito.doReturn(true).when(licenseHolder).isSet();
Mockito.doReturn(true).when(licenseHolder).isSet(); // TODO
Mockito.doReturn(false).when(licenseHolder).isExpired();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,22 +52,7 @@ public class AsAdmin {
private static final String MALFORMED_TOKEN = "hello world";

@Test
@DisplayName("GET /billing returns 200 with empty license self-hosted")
public void testGetEmptySelfHosted() {
Mockito.when(licenseHolder.get()).thenReturn(null);
Mockito.when(licenseHolder.getSeats()).thenReturn(5L);
when().get("/billing")
.then().statusCode(200)
.body("hubId", is("42"))
.body("hasLicense", is(false))
.body("email", nullValue())
.body("licensedSeats", is(5)) //community license
.body("usedSeats", is(2)) //depends on the flyway test data migration
.body("issuedAt", nullValue())
.body("expiresAt", nullValue());
}

@Test
@Order(2)
@DisplayName("PUT /billing/token returns 204 for initial token")
public void testPutInitialToken() {
given().contentType(ContentType.TEXT).body(INITIAL_TOKEN)
Expand Down
Loading