diff --git a/backend/src/main/java/org/cryptomator/hub/Main.java b/backend/src/main/java/org/cryptomator/hub/Main.java new file mode 100644 index 000000000..51b3cb625 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/Main.java @@ -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; + } + +} diff --git a/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java b/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java index 01b88fdc0..ee805b09b 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java @@ -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 getAllEvents(@QueryParam("startDate") Instant startDate, @QueryParam("endDate") Instant endDate, @QueryParam("type") List 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"); } diff --git a/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java b/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java index b849deefa..6efaf4b83 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java @@ -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 @@ -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(); diff --git a/backend/src/main/java/org/cryptomator/hub/api/LicenseResource.java b/backend/src/main/java/org/cryptomator/hub/api/LicenseResource.java index dfc1efa0c..1300bf036 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/LicenseResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/LicenseResource.java @@ -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; @@ -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 { @@ -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); } diff --git a/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java b/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java index da50595f8..352f7f348 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java @@ -292,7 +292,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); @@ -334,7 +334,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."); diff --git a/backend/src/main/java/org/cryptomator/hub/license/LicenseApi.java b/backend/src/main/java/org/cryptomator/hub/license/LicenseApi.java new file mode 100644 index 000000000..82f3a7871 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/license/LicenseApi.java @@ -0,0 +1,64 @@ +package org.cryptomator.hub.license; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Base64; + +@RegisterRestClient(configKey = "license-api") +public interface LicenseApi { + + @GET + @Path("/hub/challenge") + @Produces(MediaType.APPLICATION_JSON) + Challenge generateTrialChallenge(); + + @POST + @Path("/hub/trial") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + TrialLicenseResponse generateTrialLicense(@FormParam("captcha") String captcha); + + record Challenge(@JsonProperty("algorithm") String algorithm, + @JsonProperty("challenge") String challenge, + @JsonProperty("maxnumber") int maxnumber, + @JsonProperty("salt") String salt, + @JsonProperty("signature") String signature) { + public Solution solve(int number, long took) { + return new Solution(algorithm, challenge, number, salt, signature, took); + } + } + + record Solution(@JsonProperty("algorithm") String algorithm, + @JsonProperty("challenge") String challenge, + @JsonProperty("number") int number, + @JsonProperty("salt") String salt, + @JsonProperty("signature") String signature, + @JsonProperty("took") long took) { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + public String toCaptcha() { + try { + var serialized = OBJECT_MAPPER.writer().writeValueAsBytes(this); + return Base64.getEncoder().encodeToString(serialized); + } catch (IOException e) { + throw new UncheckedIOException("Failed to encode captcha", e); + } + } + } + + record TrialLicenseResponse(@JsonProperty("hubId") String hubId, + @JsonProperty("licenseKey") String licenseKey) {} + + +} 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 faf95471a..30253545a 100644 --- a/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java +++ b/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java @@ -1,16 +1,16 @@ package org.cryptomator.hub.license; 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.eclipse.microprofile.rest.client.inject.RestClient; import org.jboss.logging.Logger; import java.io.IOException; @@ -20,14 +20,17 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Instant; +import java.util.HexFormat; import java.util.Optional; +import java.util.UUID; @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") @@ -46,49 +49,95 @@ public class LicenseHolder { @Inject RandomMinuteSleeper randomMinuteSleeper; + @Inject Settings.Repository settingsRepo; - private static final Logger LOG = Logger.getLogger(LicenseHolder.class); + @RestClient + LicenseApi licenseApi; + private DecodedJWT license; /** - * Loads the license from the database or from init props, if present + * Makes sure a valid (but possibly expired) license exists. + *

+ * 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(settings); } } - @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(Settings settings) { + LOG.info("No license found. Requesting trial license..."); + var challenge = licenseApi.generateTrialChallenge(); + var solution = solveChallenge(challenge); + var trialResponse = licenseApi.generateTrialLicense(solution.toCaptcha()); // FIXME: is enterprise? + this.license = licenseValidator.validate(trialResponse.licenseKey(), trialResponse.hubId()); + settings.setLicenseKey(trialResponse.licenseKey()); + settings.setHubId(trialResponse.hubId()); + settingsRepo.persistAndFlush(settings); + LOG.info("Successfully retrieved trial license."); + } + + // visible for testing + LicenseApi.Solution solveChallenge(LicenseApi.Challenge challenge) { + HexFormat hex = HexFormat.of(); + MessageDigest sha256; + try { + sha256 = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError("Every implementation of the Java platform is required to support [...] SHA-256", e); + } + long start = System.nanoTime(); + for (int i = 0; i < challenge.maxnumber(); i++) { + var saltedSecret = challenge.salt() + i; + sha256.update(saltedSecret.getBytes(StandardCharsets.US_ASCII)); + var attempt = hex.formatHex(sha256.digest()); + if (challenge.challenge().equals(attempt)) { + long took = System.nanoTime() - start; + return challenge.solve(i, took / 1_000_000); + } + } + throw new IllegalArgumentException("Unsolvable challenge"); + } + /** * Parses, verifies and persists the given token as the license in the database. * @@ -116,12 +165,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); } } } @@ -146,8 +195,9 @@ String requestLicenseRefresh(URI refreshUrl, String licenseToken) throws Interru } } + @NotNull public DecodedJWT get() { - return license; + return Preconditions.checkNotNull(license); } /** @@ -155,6 +205,7 @@ public DecodedJWT get() { * * @return {@code true}, if the license _is not null_. Otherwise false. */ + @Deprecated // FIXME remove this method! public boolean isSet() { return license != null; } @@ -162,33 +213,19 @@ public boolean isSet() { /** * 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() { @@ -204,4 +241,5 @@ static class LicenseRefreshFailedException extends RuntimeException { this.body = body; } } + } 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 1813251c1..8f067d2bb 100644 --- a/backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java +++ b/backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java @@ -1,60 +1,31 @@ package org.cryptomator.hub.license; -import com.auth0.jwt.JWT; -import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.InvalidClaimException; import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.jwt.interfaces.JWTVerifier; import jakarta.enterprise.context.ApplicationScoped; -import org.jose4j.base64url.Base64; +import jakarta.inject.Inject; +import jakarta.inject.Named; -import java.security.KeyFactory; -import java.security.NoSuchAlgorithmException; -import java.security.interfaces.ECPublicKey; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.X509EncodedKeySpec; -import java.time.Instant; import java.util.Objects; -import java.util.Optional; @ApplicationScoped public class LicenseValidator { private static final String[] REQUIRED_CLAIMS = {"seats"}; - private static final String LICENSE_PUBLIC_KEY = """ - MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBjvVwj5K4/v6yq23luaEEYYG9ru6z\ - BuXeQLtZNy49FlGA5rbeumoruFVQfVPuV8R9mofxyJBpU4ixi8KGkYl+eEQBTGvN\ - EQ9Z36gBX2uZOCOfHM4x50lpwtTZ0QA3B07WPhmvupy9gZk18NHuysOd8KZFEPpG\ - YGmYBhMZXAL30qweiBQ= - """; - - private final JWTVerifier verifier; - - public LicenseValidator() { - var algorithm = Algorithm.ECDSA512(decodePublicKey(LICENSE_PUBLIC_KEY), null); - 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) { - try { - var keyBytes = Base64.decode(pemEncodedPublicKey); - var key = KeyFactory.getInstance("EC").generatePublic(new X509EncodedKeySpec(keyBytes)); - if (key instanceof ECPublicKey k) { - return k; - } else { - throw new IllegalStateException("Key not an EC public key."); - } - } catch (InvalidKeySpecException e) { - throw new IllegalArgumentException("Invalid license public key", e); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException(e); - } - } - + @Inject + @Named("licenseVerifier") + JWTVerifier verifier; + + /** + * 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)) { diff --git a/backend/src/main/java/org/cryptomator/hub/license/LicenseVerifierProducer.java b/backend/src/main/java/org/cryptomator/hub/license/LicenseVerifierProducer.java new file mode 100644 index 000000000..4975b7a6c --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/license/LicenseVerifierProducer.java @@ -0,0 +1,52 @@ +package org.cryptomator.hub.license; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.JWTVerifier; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.ws.rs.Produces; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jose4j.base64url.Base64; + +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.time.Instant; + +@ApplicationScoped +public class LicenseVerifierProducer { + + @Inject + @ConfigProperty(name = "hub.license.public-key") + String licensePublicKey; + + @Produces + @ApplicationScoped + @Named("licenseVerifier") + public JWTVerifier produceLicenseVerifier() { + var algorithm = Algorithm.ECDSA512(decodePublicKey(licensePublicKey), null); + 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 + return JWT.require(algorithm).acceptExpiresAt(expiresleeway).ignoreIssuedAt().build(); + } + + private static ECPublicKey decodePublicKey(String pemEncodedPublicKey) { + try { + var keyBytes = Base64.decode(pemEncodedPublicKey); + var key = KeyFactory.getInstance("EC").generatePublic(new X509EncodedKeySpec(keyBytes)); + if (key instanceof ECPublicKey k) { + return k; + } else { + throw new IllegalStateException("Key not an EC public key."); + } + } catch (InvalidKeySpecException e) { + throw new IllegalArgumentException("Invalid license public key", e); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index fe58990b2..0aef4fe4e 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -26,6 +26,16 @@ quarkus.oidc.application-type=service quarkus.oidc.client-id=cryptomatorhub hub.keycloak.oidc.cryptomator-client-id=cryptomator +# License Stuff +hub.license.public-key=MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBjvVwj5K4/v6yq23luaEEYYG9ru6zBuXeQLtZNy49FlGA5rbeumoruFVQfVPuV8R9mofxyJBpU4ixi8KGkYl+eEQBTGvNEQ9Z36gBX2uZOCOfHM4x50lpwtTZ0QA3B07WPhmvupy9gZk18NHuysOd8KZFEPpGYGmYBhMZXAL30qweiBQ= +%dev.hub.license.public-key=MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBiUVQ0HhZMuAOqiO2lPIT+MMSH4bcl6BOWnFn205bzTcRI9RuRdtrXVNwp/IPtjMVXTj/oW0r12HcrEdLmi9QI6QASTEByWLNTS/d94IoXmRYQTnC+RtH+H/4I1TWYw90aiig2yV0G1s0qCgAiyKswj+ST6r71NM/gepmlW3+qiv9/PU= +%test.hub.license.public-key=MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBiUVQ0HhZMuAOqiO2lPIT+MMSH4bcl6BOWnFn205bzTcRI9RuRdtrXVNwp/IPtjMVXTj/oW0r12HcrEdLmi9QI6QASTEByWLNTS/d94IoXmRYQTnC+RtH+H/4I1TWYw90aiig2yV0G1s0qCgAiyKswj+ST6r71NM/gepmlW3+qiv9/PU= +%dev.quarkus.rest-client.license-api.url=https://api.staging.cryptomator.org/licenses +%test.quarkus.rest-client.license-api.url=https://api.staging.cryptomator.org/licenses +# Alternatively, when using api locally (docker compose up --build): +#%dev.quarkus.rest-client.license-api.url=http://localhost:3300/licenses +#%test.quarkus.rest-client.license-api.url=http://localhost:3300/licenses + # Keycloak dev service %dev.quarkus.keycloak.devservices.realm-path=cryptomator-realm.json # TODO: realm-path needs to be in class path, i.e. under src/main/resources -> we might not want to include it in production jar though, so make use of maven profiles and specify optional resources https://github.com/quarkusio/quarkus-quickstarts/blob/f3f4939df30bcff062be126faaaeb58cb7c79fb6/security-keycloak-authorization-quickstart/pom.xml#L68-L75 diff --git a/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceIT.java index 4a75e9a4b..555750567 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceIT.java @@ -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(); } diff --git a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceIT.java index da47f9ffe..df1cb75ee 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceIT.java @@ -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) diff --git a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceIT.java b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceIT.java index 53d3b8483..f84c81dfa 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceIT.java @@ -1,7 +1,5 @@ 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; @@ -12,15 +10,14 @@ import jakarta.inject.Inject; import org.cryptomator.hub.license.LicenseHolder; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.sql.SQLException; import java.util.Map; import static io.restassured.RestAssured.when; import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.nullValue; @QuarkusTest @DisplayName("Resource /billing managed instance") @@ -32,12 +29,16 @@ public class BillingResourceManagedInstanceIT { @Inject - AgroalDataSource dataSource; + LicenseHolder licenseHolder; @BeforeAll public static void beforeAll() { RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); - Arc.container().instance(LicenseHolder.class).destroy(); + } + + @BeforeEach + public void setup() { + licenseHolder.ensureLicenseExists(); } public static class ManagedInstanceTestProfile implements QuarkusTestProfile { @@ -48,24 +49,17 @@ public Map getConfigOverrides() { } @Test - @DisplayName("GET /billing returns 401 with empty license managed instance") - public void testGetEmptyManagedInstance() throws SQLException { - try (var c = dataSource.getConnection(); var s = c.createStatement()) { - s.execute(""" - UPDATE "settings" - SET "hub_id" = '42', "license_key" = null - WHERE "id" = 0; - """); - } - + @DisplayName("GET /billing returns 200 billing data with managedInstance=true") + public void testGetInitial() { when().get("/billing") .then().statusCode(200) .body("hubId", is("42")) - .body("hasLicense", is(false)) - .body("email", nullValue()) - .body("licensedSeats", is(0)) + .body("hasLicense", is(true)) + .body("email", is("hub@cryptomator.org")) + .body("licensedSeats", is(5)) .body("usedSeats", is(2)) - .body("issuedAt", nullValue()) - .body("expiresAt", nullValue()); + .body("issuedAt", is("2022-03-23T15:29:20Z")) + .body("expiresAt", is("9999-12-31T00:00:00Z")) + .body("managedInstance", is(true)); } } \ No newline at end of file diff --git a/backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java index cd905e36c..b7c7f51b8 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java @@ -339,7 +339,7 @@ public void test999Gets998() { @TestSecurity(user = "Admin", roles = {"admin"}) @DisplayName("As admin, GET /auditlog contains signature events") public void testGetAuditLogEntries() { - Mockito.doReturn(true).when(licenseHolder).isSet(); + Mockito.doReturn(true).when(licenseHolder).isSet(); // TODO Mockito.doReturn(false).when(licenseHolder).isExpired(); given().param("startDate", DateTimeFormatter.ISO_INSTANT.format(testStart)) diff --git a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java index b80fa56be..dec3aec7b 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java @@ -2,8 +2,10 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.DecodedJWT; import io.agroal.api.AgroalDataSource; import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.TestSecurity; import io.quarkus.test.security.oidc.Claim; @@ -14,6 +16,7 @@ import jakarta.validation.Validator; import org.cryptomator.hub.entities.EffectiveVaultAccess; import org.cryptomator.hub.entities.Vault; +import org.cryptomator.hub.license.LicenseHolder; import org.cryptomator.hub.rollback.DBRollbackAfter; import org.cryptomator.hub.rollback.DBRollbackBefore; import org.flywaydb.core.Flyway; @@ -23,6 +26,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Nested; @@ -32,6 +36,7 @@ import org.junit.jupiter.api.TestMethodOrder; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mockito; import java.security.GeneralSecurityException; import java.security.KeyFactory; @@ -71,6 +76,10 @@ public class VaultResourceIT { Vault.Repository vaultRepo; @Inject Validator validator; + @InjectMock + LicenseHolder licenseHolder; + + @SuppressWarnings("unused") // used by @DBRollbackAfter annotation @Inject public Flyway flyway; @@ -79,6 +88,14 @@ public static void beforeAll() { RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); } + @BeforeEach + public void setup() { +// var decodedJWT = Mockito.mock(DecodedJWT.class); +// Mockito.doReturn(decodedJWT).when(licenseHolder).get(); + Mockito.doReturn(false).when(licenseHolder).isExpired(); + Mockito.doReturn(5L).when(licenseHolder).getSeats(); + } + private static PrivateKey getPrivateKey(String keyBytes) throws NoSuchAlgorithmException, InvalidKeySpecException { return KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyBytes))); } @@ -191,7 +208,7 @@ public void testUnlockArchived2() { @Test @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-00010000AAAA/access-token returns 200 for archived vaults with evenIfArchived set to true") - public void testUnlockArchived3() throws SQLException { + public void testUnlockArchived3() { when().get("/vaults/{vaultId}/access-token?evenIfArchived=true", "7E57C0DE-0000-4000-8000-00010000AAAA") .then().statusCode(200); } 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 2e2e7db6e..491e79045 100644 --- a/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java +++ b/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java @@ -10,7 +10,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mockito; @@ -24,16 +24,14 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; public class LicenseHolderTest { Settings.Repository settingsRepo = mock(Settings.Repository.class); RandomMinuteSleeper randomMinuteSleeper = mock(RandomMinuteSleeper.class); LicenseValidator validator = mock(LicenseValidator.class); + LicenseApi licenseApi = mock(LicenseApi.class); LicenseHolder licenseHolder; @@ -43,79 +41,80 @@ public void resetTestclass() { licenseHolder.licenseValidator = validator; licenseHolder.settingsRepo = settingsRepo; licenseHolder.randomMinuteSleeper = randomMinuteSleeper; + licenseHolder.licenseApi = licenseApi; } @Nested - @DisplayName("Testing Init Method") - class TestInit { + @DisplayName("Testing ensureLicenseExists()") + class TestEnsureLicenseExists { + + private Settings settings; + private LicenseHolder licenseHolderSpy; + + @BeforeEach + public void setup() { + settings = mock(Settings.class); + licenseHolderSpy = Mockito.spy(licenseHolder); + Mockito.doReturn(settings).when(settingsRepo).get(); + Mockito.doNothing().when(licenseHolderSpy).validateExistingLicense(any()); + Mockito.doNothing().when(licenseHolderSpy).validateAndApplyInitLicense(any(), any(), any()); + Mockito.doNothing().when(licenseHolderSpy).requestAnonTrialLicense(settings); + } @Test - @DisplayName("If db token and hubId is set, call validateExisting") - public void testTokenAndIdPresentInDatabase() { + @DisplayName("call validateExistingLicense(), if DB contains existing token") + public void testValidateExistingLicense() { //to show check, that db has higher precedence - licenseHolder.initialId = Optional.of("43"); - licenseHolder.initialLicenseToken = Optional.of("initToken"); - - Settings settings = mock(Settings.class); + licenseHolderSpy.initialId = Optional.of("43"); + licenseHolderSpy.initialLicenseToken = Optional.of("initToken"); when(settings.getLicenseKey()).thenReturn("token"); when(settings.getHubId()).thenReturn("42"); - when(settingsRepo.get()).thenReturn(settings); - var licenseHolderSpy = Mockito.spy(licenseHolder); - licenseHolderSpy.init(); - verify(licenseHolderSpy).validateOrResetExistingLicense(settings); + licenseHolderSpy.ensureLicenseExists(); + + verify(licenseHolderSpy).validateExistingLicense(settings); + verify(licenseHolderSpy, never()).validateAndApplyInitLicense(any(), any(), any()); + verify(licenseHolderSpy, never()).requestAnonTrialLicense(settings); } - @DisplayName("If dbToken or dbHubId is null, use set init config values") + @DisplayName("call validateAndApplyInitLicense(), if DB doesn't contain token but init config does") @ParameterizedTest - @MethodSource("provideInitValuesCases") - public void testInitValues(String dbToken, String dbHubId) { - Settings settings = mock(Settings.class); + @CsvSource(value = { + "dbToken, null", + "null, null", + "null, 42" + }, nullValues = {"null"}) + public void testApplyInitLicense(String dbToken, String dbHubId) { + licenseHolderSpy.initialLicenseToken = Optional.of("token"); + licenseHolderSpy.initialId = Optional.of("43"); when(settings.getLicenseKey()).thenReturn(dbToken); when(settings.getHubId()).thenReturn(dbHubId); - when(settingsRepo.get()).thenReturn(settings); - licenseHolder.initialLicenseToken = Optional.of("token"); - licenseHolder.initialId = Optional.of("43"); + licenseHolderSpy.ensureLicenseExists(); - var licenseHolderSpy = Mockito.spy(licenseHolder); - licenseHolderSpy.init(); + verify(licenseHolderSpy, never()).validateExistingLicense(any()); verify(licenseHolderSpy).validateAndApplyInitLicense(settings, "token", "43"); + verify(licenseHolderSpy, never()).requestAnonTrialLicense(settings); } - public static Stream provideInitValuesCases() { - return Stream.of( - Arguments.of("dbToken", null), - Arguments.of(null, null), - Arguments.of(null, "42") - ); - } - - @DisplayName("Do nothing, if db and init have a null value") + @DisplayName("call requestAnonTrialLicense(), if neither DB nor init config contains token") @ParameterizedTest - @MethodSource("provideDoNothingCases") - public void testDoNothingCases(String dbToken, String dbHubId, String initToken, String initId) { - Settings settings = mock(Settings.class); + @CsvSource(value = { + "dbToken, null, null, 43", + "null, 42, null, 43", + "dbToken, null, initToken, null" + }, nullValues = {"null"}) + public void testRequestTrialLicense(String dbToken, String dbHubId, String initToken, String initId) { + licenseHolderSpy.initialLicenseToken = Optional.ofNullable(initToken); + licenseHolderSpy.initialId = Optional.ofNullable(initId); when(settings.getLicenseKey()).thenReturn(dbToken); when(settings.getHubId()).thenReturn(dbHubId); - when(settingsRepo.get()).thenReturn(settings); - licenseHolder.initialLicenseToken = Optional.ofNullable(initToken); - licenseHolder.initialId = Optional.ofNullable(initId); + licenseHolderSpy.ensureLicenseExists(); - var licenseHolderSpy = Mockito.spy(licenseHolder); - licenseHolderSpy.init(); - verify(licenseHolderSpy, never()).validateOrResetExistingLicense(settings); + verify(licenseHolderSpy, never()).validateExistingLicense(settings); verify(licenseHolderSpy, never()).validateAndApplyInitLicense(Mockito.eq(settings), any(), any()); - } - - public static Stream provideDoNothingCases() { - return Stream.of( - Arguments.of("dbToken", null, null, "43"), - Arguments.of(null, "42", null, "43"), - Arguments.of(null, "42", "initToken", null), - Arguments.of("dbToken", null, "initToken", null) - ); + verify(licenseHolderSpy).requestAnonTrialLicense(settings); } } @@ -126,38 +125,34 @@ public void testValidateExistingSuccess() { when(settings.getLicenseKey()).thenReturn("token"); when(settings.getHubId()).thenReturn("42"); when(settingsRepo.get()).thenReturn(settings); - when(validator.validate("token", "42")).thenReturn(Mockito.mock(DecodedJWT.class)); - licenseHolder.validateOrResetExistingLicense(settings); + licenseHolder.validateExistingLicense(settings); + verify(settings, never()).setHubId(any()); verify(settings, never()).setLicenseKey(any()); } @Test - @DisplayName("Invalid db token is set to null and persisted") + @DisplayName("Invalid db token fails validation") public void testValidateExistingFailure() { Settings settings = mock(Settings.class); when(settings.getLicenseKey()).thenReturn("token"); when(settings.getHubId()).thenReturn("42"); when(settingsRepo.get()).thenReturn(settings); - when(validator.validate("token", "42")).thenThrow(JWTVerificationException.class); - licenseHolder.validateOrResetExistingLicense(settings); - verify(settings, never()).setHubId(any()); - verify(settings).setLicenseKey(Mockito.isNull()); - verify(settingsRepo).persistAndFlush(settings); + Assertions.assertThrows(JWTVerificationException.class, () -> licenseHolder.validateExistingLicense(settings)); } @Test @DisplayName("Valid init token is persisted with hubID to db") public void testApplyInitSuccess() { Settings settings = mock(Settings.class); - when(validator.validate("token", "42")).thenReturn(Mockito.mock(DecodedJWT.class)); licenseHolder.validateAndApplyInitLicense(settings, "token", "42"); + verify(settings).setHubId("42"); verify(settings).setLicenseKey("token"); verify(settingsRepo).persistAndFlush(settings); @@ -170,14 +165,38 @@ public void testApplyInitFailure() { when(settings.getLicenseKey()).thenReturn("token"); when(settings.getHubId()).thenReturn("42"); when(settingsRepo.get()).thenReturn(settings); - when(validator.validate("token", "42")).thenThrow(JWTVerificationException.class); - licenseHolder.validateAndApplyInitLicense(settings, "token", "42"); + Assertions.assertThrows(JWTVerificationException.class, () -> licenseHolder.validateAndApplyInitLicense(settings, "token", "42")); + verify(settings, never()).setHubId(any()); verify(settings, never()).setLicenseKey(any()); } + @Test + @DisplayName("Requesting a trial license contacts the license server") + public void testRequestAnonTrialLicense() { + LicenseHolder licenseHolderSpy = Mockito.spy(licenseHolder); + Settings settings = mock(Settings.class); + LicenseApi.Challenge challenge = mock(LicenseApi.Challenge.class); + LicenseApi.Solution solution = mock(LicenseApi.Solution.class); + LicenseApi.TrialLicenseResponse trialLicenseResponse = mock(LicenseApi.TrialLicenseResponse.class); + doReturn(challenge).when(licenseApi).generateTrialChallenge(); + doReturn("captcha").when(solution).toCaptcha(); + doReturn(solution).when(licenseHolderSpy).solveChallenge(challenge); + doReturn(trialLicenseResponse).when(licenseApi).generateTrialLicense("captcha"); + doReturn("token").when(trialLicenseResponse).licenseKey(); + doReturn(mock(DecodedJWT.class)).when(validator).validate(Mockito.eq("token"), Mockito.any()); + + licenseHolderSpy.requestAnonTrialLicense(settings); + + verify(licenseApi).generateTrialChallenge(); + verify(licenseApi).generateTrialLicense("captcha"); + verify(settings).setHubId(Mockito.any()); + verify(settings).setLicenseKey("token"); + verify(settingsRepo).persistAndFlush(settings); + } + @Nested @DisplayName("Testing set() method") class TestSetter { @@ -208,18 +227,15 @@ public void testSetValidToken() { @Test @DisplayName("Setting an invalid token fails with exception") public void testSetInvalidToken() { - when(validator.validate("token", "42")).thenAnswer(invocationOnMock -> { - throw new JWTVerificationException(""); - }); Settings settings = mock(Settings.class); - when(settings.getHubId()).thenReturn("42"); - when(settingsRepo.get()).thenReturn(settings); + Mockito.doReturn(settings).when(settingsRepo).get(); + Mockito.doReturn("42").when(settings).getHubId(); + Mockito.doThrow(new JWTVerificationException("")).when(validator).validate("token", "42"); Assertions.assertThrows(JWTVerificationException.class, () -> licenseHolder.set("token")); verify(validator).validate("token", "42"); verify(settingsRepo, never()).persist((Settings) any()); - Assertions.assertNull(licenseHolder.get()); //TODO: not very unit test like } } @@ -227,14 +243,26 @@ public void testSetInvalidToken() { @DisplayName("Testing refreshLicense()") class RefreshLicense { + private LicenseHolder licenseHolderSpy; + private Claim refreshClaim; + private DecodedJWT licenseJwt; + + @BeforeEach + public void setup() { + licenseHolderSpy = Mockito.spy(licenseHolder); + refreshClaim = mock(Claim.class); + licenseJwt = mock(DecodedJWT.class); + + Mockito.doReturn("http://localhost:3000").when(refreshClaim).asString(); + Mockito.doReturn(refreshClaim).when(licenseJwt).getClaim("refreshUrl"); + Mockito.doReturn("token").when(licenseJwt).getToken(); + Mockito.doReturn(licenseJwt).when(licenseHolderSpy).get(); + } + @Test @DisplayName("If license does not have a refreshUrl, skip refresh") public void testRefreshLicenseNoRefreshURL() throws InterruptedException, IOException { - var licenseHolderSpy = Mockito.spy(licenseHolder); - - var licenseJwt = mock(DecodedJWT.class); - when(licenseJwt.getClaim("refreshUrl")).thenReturn(null); - when(licenseHolderSpy.get()).thenReturn(licenseJwt); + Mockito.doReturn(null).when(licenseJwt).getClaim("refreshUrl"); licenseHolderSpy.refreshLicense(); @@ -248,13 +276,7 @@ public void testRefreshLicenseNoRefreshURL() throws InterruptedException, IOExce @Test @DisplayName("If license does not have a valid refreshUrl, skip refresh") public void testRefreshLicenseBadURL() throws InterruptedException, IOException { - var licenseHolderSpy = Mockito.spy(licenseHolder); - - var refreshClaim = mock(Claim.class); - when(refreshClaim.asString()).thenReturn("*:not:an::uri"); - var licenseJwt = mock(DecodedJWT.class); - when(licenseJwt.getClaim("refreshUrl")).thenReturn(refreshClaim); - when(licenseHolderSpy.get()).thenReturn(licenseJwt); + Mockito.doReturn("*:not:an::uri").when(refreshClaim).asString(); licenseHolderSpy.refreshLicense(); @@ -268,14 +290,6 @@ public void testRefreshLicenseBadURL() throws InterruptedException, IOException @ParameterizedTest @MethodSource("provideRefreshLicenseFailingRequestCases") public void testRefreshLicenseFailingRequest(Throwable t) throws InterruptedException, IOException { - var licenseHolderSpy = Mockito.spy(licenseHolder); - - var refreshClaim = mock(Claim.class); - when(refreshClaim.asString()).thenReturn("http://localhost:3000"); - var licenseJwt = mock(DecodedJWT.class); - when(licenseJwt.getClaim("refreshUrl")).thenReturn(refreshClaim); - when(licenseJwt.getToken()).thenReturn("token"); - when(licenseHolderSpy.get()).thenReturn(licenseJwt); Mockito.doThrow(t).when(licenseHolderSpy).requestLicenseRefresh(any(), eq("token")); licenseHolderSpy.refreshLicense(); @@ -293,14 +307,6 @@ static Stream provideRefreshLicenseFailingRequestCases() { @Test @DisplayName("Successful refresh request, but failing validation") public void testRefreshLicenseFailedValidation() throws InterruptedException, IOException { - var licenseHolderSpy = Mockito.spy(licenseHolder); - - var refreshClaim = mock(Claim.class); - when(refreshClaim.asString()).thenReturn("http://localhost:3000"); - var licenseJwt = mock(DecodedJWT.class); - when(licenseJwt.getClaim("refreshUrl")).thenReturn(refreshClaim); - when(licenseJwt.getToken()).thenReturn("token"); - when(licenseHolderSpy.get()).thenReturn(licenseJwt); Mockito.doReturn("newToken").when(licenseHolderSpy).requestLicenseRefresh(any(), eq("token")); Mockito.doThrow(JWTVerificationException.class).when(licenseHolderSpy).set("newToken"); @@ -313,25 +319,20 @@ public void testRefreshLicenseFailedValidation() throws InterruptedException, IO } @Test - @DisplayName("Successful refresh request, but failing validation") + @DisplayName("Successful refresh") public void testRefreshLicenseSuccess() throws InterruptedException, IOException { - var licenseHolderSpy = Mockito.spy(licenseHolder); - - var refreshClaim = mock(Claim.class); - when(refreshClaim.asString()).thenReturn("http://localhost:3000"); - var licenseJwt = mock(DecodedJWT.class); - when(licenseJwt.getClaim("refreshUrl")).thenReturn(refreshClaim); - when(licenseJwt.getToken()).thenReturn("token"); - when(licenseHolderSpy.get()).thenReturn(licenseJwt); + var settings = Mockito.mock(Settings.class); + Mockito.doReturn("42").when(settings).getHubId(); + Mockito.doReturn(settings).when(settingsRepo).get(); Mockito.doReturn("newToken").when(licenseHolderSpy).requestLicenseRefresh(any(), eq("token")); - Mockito.doThrow(JWTVerificationException.class).when(licenseHolderSpy).set("newToken"); licenseHolderSpy.refreshLicense(); verify(licenseHolderSpy).requestLicenseRefresh(any(), eq("token")); verify(licenseHolderSpy).set("newToken"); - verify(settingsRepo, never()).get(); - verify(settingsRepo, never()).persistAndFlush(any()); + verify(validator).validate("newToken", "42"); + verify(settings).setLicenseKey("newToken"); + verify(settingsRepo).persistAndFlush(settings); } } 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 e0c63ac29..1f1e93105 100644 --- a/backend/src/test/java/org/cryptomator/hub/license/LicenseValidatorTest.java +++ b/backend/src/test/java/org/cryptomator/hub/license/LicenseValidatorTest.java @@ -4,11 +4,10 @@ import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.exceptions.SignatureVerificationException; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.util.Optional; - public class LicenseValidatorTest { private static final String VALID_TOKEN = "eyJhbGciOiJFUzUxMiJ9.eyJqdGkiOiI0MiIsImlhdCI6MTY0ODA0OTM2MCwiaXNzIjoiU2t5bWF0aWMiLCJhdWQiOiJDcnlwdG9tYXRvciBIdWIiLCJzdWIiOiJodWJAY3J5cHRvbWF0b3Iub3JnIiwic2VhdHMiOjUsImV4cCI6MjUzNDAyMjE0NDAwLCJyZWZyZXNoVXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2h1Yi9zdWJzY3JpcHRpb24_aHViX2lkPTQyIn0.AKyoZ0WQ8xhs8vPymWPHCsc6ch6pZpfxBcrF5QjVLSQVnYz2s5QF3nnkwn4AGR7V14TuhkJMZLUZxMdQAYLyL95sAV2Fu0E4-e1v3IVKlNKtze89eqYvEs6Ak9jWjtecOgPWNWjz2itI4MfJBDmbFtTnehOtqRqUdsDoC9NFik2C7tHm"; @@ -16,9 +15,17 @@ public class LicenseValidatorTest { 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"; + private static final String LICENSE_PUBLIC_KEY = "MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBjvVwj5K4/v6yq23luaEEYYG9ru6zBuXeQLtZNy49FlGA5rbeumoruFVQfVPuV8R9mofxyJBpU4ixi8KGkYl+eEQBTGvNEQ9Z36gBX2uZOCOfHM4x50lpwtTZ0QA3B07WPhmvupy9gZk18NHuysOd8KZFEPpGYGmYBhMZXAL30qweiBQ="; LicenseValidator validator = new LicenseValidator(); + @BeforeEach + public void setup() { + var verifierProducer = new LicenseVerifierProducer(); + verifierProducer.licensePublicKey = LICENSE_PUBLIC_KEY; + validator.verifier = verifierProducer.produceLicenseVerifier(); + } + @Test @DisplayName("validate valid token") public void testValidateValidToken() { diff --git a/backend/src/test/resources/org/cryptomator/hub/flyway/V9999__Test_Data.sql b/backend/src/test/resources/org/cryptomator/hub/flyway/V9999__Test_Data.sql index ee02b474b..59b06abbf 100644 --- a/backend/src/test/resources/org/cryptomator/hub/flyway/V9999__Test_Data.sql +++ b/backend/src/test/resources/org/cryptomator/hub/flyway/V9999__Test_Data.sql @@ -4,7 +4,7 @@ UPDATE "settings" SET "hub_id" = '42', - "license_key" = 'eyJhbGciOiJFUzUxMiJ9.eyJqdGkiOiI0MiIsImlhdCI6MTY0ODA0OTM2MCwiaXNzIjoiU2t5bWF0aWMiLCJhdWQiOiJDcnlwdG9tYXRvciBIdWIiLCJzdWIiOiJodWJAY3J5cHRvbWF0b3Iub3JnIiwic2VhdHMiOjUsImV4cCI6MjUzNDAyMjE0NDAwLCJyZWZyZXNoVXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2h1Yi9zdWJzY3JpcHRpb24_aHViX2lkPTQyIn0.AKyoZ0WQ8xhs8vPymWPHCsc6ch6pZpfxBcrF5QjVLSQVnYz2s5QF3nnkwn4AGR7V14TuhkJMZLUZxMdQAYLyL95sAV2Fu0E4-e1v3IVKlNKtze89eqYvEs6Ak9jWjtecOgPWNWjz2itI4MfJBDmbFtTnehOtqRqUdsDoC9NFik2C7tHm' + "license_key" = 'eyJhbGciOiJFUzUxMiJ9.eyJqdGkiOiI0MiIsImlhdCI6MTY0ODA0OTM2MCwiaXNzIjoiU2t5bWF0aWMiLCJhdWQiOiJDcnlwdG9tYXRvciBIdWIiLCJzdWIiOiJodWJAY3J5cHRvbWF0b3Iub3JnIiwic2VhdHMiOjUsImV4cCI6MjUzNDAyMjE0NDAwLCJyZWZyZXNoVXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2h1Yi9zdWJzY3JpcHRpb24_aHViX2lkPTQyIn0.AHPY1r-KmULaNTFTYrGUZrFZny2zouW9BcICBhs_bD_juv_evnOpbwbZAYZC7k8dx0s_94eBF82stgjW9HbBj4wnAHQohMCMK8Gq4b8TyJqBWPnk36KIK40V7qMv9hyrJG5aTfI3Q2D7fAnKCUxwO2v8t6lA4g89acJBbGipHh_2HhD7' WHERE "id" = 0; INSERT INTO "authority" ("id", "type", "name") diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index 97b2cbc95..a0ce5410c 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -104,7 +104,7 @@ export type TrustDto = { export type BillingDto = { hubId: string; - hasLicense: boolean; + hasLicense: boolean; // TODO remove email: string; licensedSeats: number; usedSeats: number; @@ -137,7 +137,7 @@ export class LicenseUserInfoDto { } public isExceeded(): boolean { - return this.usedSeats > this.licensedSeats; + return this.licensedSeats == 0 || this.usedSeats > this.licensedSeats; } } diff --git a/frontend/src/components/AdminSettings.vue b/frontend/src/components/AdminSettings.vue index ccda5022a..d3f552f15 100644 --- a/frontend/src/components/AdminSettings.vue +++ b/frontend/src/components/AdminSettings.vue @@ -1,5 +1,5 @@