Skip to content

Commit de6d784

Browse files
Merge pull request cryptomator#318 from cryptomator/feature/non-null-licensekey
non-null license keys
2 parents dd9a51f + c06fb27 commit de6d784

27 files changed

Lines changed: 577 additions & 361 deletions
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: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,17 @@ public class AuditLogResource {
6868
@APIResponse(responseCode = "402", description = "Community license used or license expired")
6969
@APIResponse(responseCode = "403", description = "requesting user does not have admin role")
7070
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) {
71-
if (!license.isSet() || license.isExpired()) {
71+
if (license.getEntitlements().auditLogRetentionDays() == 0 || license.isExpired()) {
7272
throw new PaymentRequiredException("Community license used or license expired");
7373
}
74+
Instant retentionThreshold = license.getEntitlements().auditLogRetentionThreshold();
7475

7576
if (startDate == null || endDate == null) {
7677
throw new BadRequestException("startDate and endDate must be specified");
7778
} else if (startDate.isAfter(endDate)) {
7879
throw new BadRequestException("startDate must be before endDate");
80+
} else if (endDate.isBefore(retentionThreshold)) {
81+
throw new PaymentRequiredException("queried date range predates audit log retention period");
7982
} else if (!(order.equals("desc") || order.equals("asc"))) {
8083
throw new BadRequestException("order must be either 'asc' or 'desc'");
8184
} else if (pageSize < 1 || pageSize > 100) {
@@ -92,6 +95,9 @@ public List<AuditEventDto> getAllEvents(@QueryParam("startDate") Instant startDa
9295
}
9396
}
9497

98+
// cut off startDate at retention threshold
99+
startDate = startDate.isBefore(retentionThreshold) ? retentionThreshold : startDate;
100+
95101
return auditEventRepo.findAllInPeriod(startDate, endDate, type, paginationId, order.equals("asc"), pageSize).map(AuditEventDto::fromEntity).toList();
96102
}
97103

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

Lines changed: 5 additions & 13 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
@@ -71,21 +67,17 @@ public Response setToken(@NotNull @ValidJWS String token) {
7167
}
7268
}
7369

74-
public record BillingDto(@JsonProperty("hubId") String hubId, @JsonProperty("hasLicense") Boolean hasLicense, @JsonProperty("email") String email,
70+
public record BillingDto(@JsonProperty("hubId") String hubId, @JsonProperty("email") String email,
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();
85-
var licensedSeats = jwt.getClaim("seats").asInt();
77+
var licensedSeats = jwt.getClaim("seats").asInt(); // TODO eventually replace with "org.cryptomator.hub.entitlements"."seats", see https://github.com/cryptomator/hub/issues/391
8678
var issuedAt = jwt.getIssuedAt().toInstant();
8779
var expiresAt = jwt.getExpiresAt().toInstant();
88-
return new BillingDto(id, true, email, licensedSeats, usedSeats, issuedAt, expiresAt, isManaged);
80+
return new BillingDto(id, email, licensedSeats, usedSeats, issuedAt, expiresAt, isManaged);
8981
}
9082

9183
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import jakarta.ws.rs.Path;
99
import jakarta.ws.rs.Produces;
1010
import jakarta.ws.rs.core.MediaType;
11+
import org.cryptomator.hub.license.HubLicenseEntitlements;
12+
import org.cryptomator.hub.license.LicenseHolder;
1113
import org.eclipse.microprofile.config.inject.ConfigProperty;
1214

1315
import java.time.Instant;
@@ -39,6 +41,8 @@ public class ConfigResource {
3941
@Inject
4042
OidcConfigurationMetadata oidcConfData;
4143

44+
@Inject
45+
LicenseHolder license;
4246

4347
@PermitAll
4448
@GET
@@ -49,7 +53,7 @@ public ConfigDto getConfig() {
4953
var authUri = replacePrefix(oidcConfData.getAuthorizationUri(), trimTrailingSlash(internalRealmUrl), publicRealmUri);
5054
var tokenUri = replacePrefix(oidcConfData.getTokenUri(), trimTrailingSlash(internalRealmUrl), publicRealmUri);
5155

52-
return new ConfigDto(keycloakPublicUrl, keycloakRealm, keycloakClientIdHub, keycloakClientIdCryptomator, authUri, tokenUri, Instant.now().truncatedTo(ChronoUnit.MILLIS), 4);
56+
return new ConfigDto(keycloakPublicUrl, keycloakRealm, keycloakClientIdHub, keycloakClientIdCryptomator, authUri, tokenUri, Instant.now().truncatedTo(ChronoUnit.MILLIS), 4, license.getEntitlements());
5357
}
5458

5559
//visible for testing
@@ -75,7 +79,8 @@ String trimTrailingSlash(String str) {
7579
public record ConfigDto(@JsonProperty("keycloakUrl") String keycloakUrl, @JsonProperty("keycloakRealm") String keycloakRealm,
7680
@JsonProperty("keycloakClientIdHub") String keycloakClientIdHub, @JsonProperty("keycloakClientIdCryptomator") String keycloakClientIdCryptomator,
7781
@JsonProperty("keycloakAuthEndpoint") String authEndpoint, @JsonProperty("keycloakTokenEndpoint") String tokenEndpoint,
78-
@JsonProperty("serverTime") Instant serverTime, @JsonProperty("apiLevel") Integer apiLevel) {
82+
@JsonProperty("serverTime") Instant serverTime, @JsonProperty("apiLevel") Integer apiLevel,
83+
@JsonProperty("entitlements") HubLicenseEntitlements entitlements) {
7984
}
8085

8186
}

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

Lines changed: 2 additions & 4 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 {
@@ -41,8 +39,8 @@ public record LicenseUserInfoDto(@JsonProperty("licensedSeats") Integer licensed
4139
@JsonProperty("expiresAt") Instant expiresAt) {
4240

4341
public static LicenseUserInfoDto create(LicenseHolder licenseHolder, int usedSeats) {
44-
var licensedSeats = (int) licenseHolder.getSeats();
45-
var expiresAt = Optional.ofNullable(licenseHolder.get()).map(DecodedJWT::getExpiresAtAsInstant).orElse(null);
42+
var licensedSeats = (int) licenseHolder.getEntitlements().seats();
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: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ public Response addUser(@PathParam("vaultId") UUID vaultId, @PathParam("userId")
179179
var vault = vaultRepo.findById(vaultId); // should always be found, since @VaultRole filter would have triggered
180180
var user = userRepo.findByIdOptional(userId).orElseThrow(NotFoundException::new);
181181
var usedSeats = effectiveVaultAccessRepo.countSeatOccupyingUsers();
182-
if (usedSeats < license.getSeats() // free seats available
182+
if (usedSeats < license.getEntitlements().seats() // free seats available
183183
|| effectiveVaultAccessRepo.isUserOccupyingSeat(userId)) { // or user already sitting
184184
return addAuthority(vault, user, role);
185185
} else {
@@ -206,7 +206,7 @@ public Response addGroup(@PathParam("vaultId") UUID vaultId, @PathParam("groupId
206206
var group = groupRepo.findByIdOptional(groupId).orElseThrow(NotFoundException::new);
207207

208208
//usersInGroup - usersInGroupAndPartOfAtLeastOneVault + usersOfAtLeastOneVault
209-
if (userRepo.countEffectiveGroupUsers(groupId) - effectiveVaultAccessRepo.countSeatOccupyingUsersOfGroup(groupId) + effectiveVaultAccessRepo.countSeatOccupyingUsers() > license.getSeats()) {
209+
if (userRepo.countEffectiveGroupUsers(groupId) - effectiveVaultAccessRepo.countSeatOccupyingUsersOfGroup(groupId) + effectiveVaultAccessRepo.countSeatOccupyingUsers() > license.getEntitlements().seats()) {
210210
throw new PaymentRequiredException("Adding this group would exceed available license seats.");
211211
}
212212

@@ -286,16 +286,24 @@ public Response legacyUnlock(@PathParam("vaultId") UUID vaultId, @PathParam("dev
286286
}
287287

288288
var accessTokenSeats = effectiveVaultAccessRepo.countSeatOccupyingUsersWithAccessToken();
289-
if (accessTokenSeats > license.getSeats()) {
289+
if (accessTokenSeats > license.getEntitlements().seats()) {
290290
throw new PaymentRequiredException("Number of effective vault users exceeds available license seats");
291291
}
292292
var ipAddress = request.remoteAddress().hostAddress();
293293
try {
294294
var access = legacyAccessTokenRepo.unlock(vaultId, deviceId, jwt.getSubject());
295295
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.SUCCESS, ipAddress, deviceId);
296-
var subscriptionStateHeaderName = "Hub-Subscription-State";
297-
var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter
298-
return Response.ok(access.getJwe()).header(subscriptionStateHeaderName, subscriptionStateHeaderValue).build();
296+
var response = Response.ok(access.getJwe());
297+
var iosLicense = license.getEntitlements().iosLicense();
298+
var androidLicense = license.getEntitlements().androidLicense();
299+
if (iosLicense != null) {
300+
response = response.header("Hub-Subscription-State", "ACTIVE"); // license expiration is not checked here, because it is checked in the ActiveLicense filter
301+
response = response.header("Hub-iOS-License", iosLicense);
302+
}
303+
if (androidLicense != null) {
304+
response = response.header("Hub-Android-License", androidLicense);
305+
}
306+
return response.build();
299307
} catch (NoResultException e) {
300308
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.UNAUTHORIZED, ipAddress, deviceId);
301309
throw new ForbiddenException("Access to this device not granted.");
@@ -322,7 +330,7 @@ public Response unlock(@PathParam("vaultId") UUID vaultId, @QueryParam("evenIfAr
322330
}
323331

324332
var accessTokenSeats = effectiveVaultAccessRepo.countSeatOccupyingUsersWithAccessToken();
325-
if (accessTokenSeats > license.getSeats()) {
333+
if (accessTokenSeats > license.getEntitlements().seats()) {
326334
throw new PaymentRequiredException("Number of effective vault users exceeds available license seats");
327335
}
328336

@@ -335,9 +343,17 @@ public Response unlock(@PathParam("vaultId") UUID vaultId, @QueryParam("evenIfAr
335343
var access = accessTokenRepo.unlock(vaultId, jwt.getSubject());
336344
if (access != null) {
337345
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.SUCCESS, ipAddress, deviceId);
338-
var subscriptionStateHeaderName = "Hub-Subscription-State";
339-
var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter
340-
return Response.ok(access.getVaultKey(), MediaType.TEXT_PLAIN_TYPE).header(subscriptionStateHeaderName, subscriptionStateHeaderValue).build();
346+
var response = Response.ok(access.getVaultKey(), MediaType.TEXT_PLAIN_TYPE);
347+
var iosLicense = license.getEntitlements().iosLicense();
348+
var androidLicense = license.getEntitlements().androidLicense();
349+
if (iosLicense != null) {
350+
response = response.header("Hub-Subscription-State", "ACTIVE"); // license expiration is not checked here, because it is checked in the ActiveLicense filter
351+
response = response.header("Hub-iOS-License", iosLicense);
352+
}
353+
if (androidLicense != null) {
354+
response = response.header("Hub-Android-License", androidLicense);
355+
}
356+
return response.build();
341357
} else if (vaultRepo.findById(vaultId) == null) {
342358
throw new NotFoundException("No such vault.");
343359
} else {
@@ -364,7 +380,7 @@ public Response grantAccess(@PathParam("vaultId") UUID vaultId, @NotEmpty Map<St
364380
long occupiedSeats = effectiveVaultAccessRepo.countSeatOccupyingUsers();
365381
long usersWithoutSeat = tokens.size() - effectiveVaultAccessRepo.countSeatsOccupiedByUsers(tokens.keySet().stream().toList());
366382

367-
if (occupiedSeats + usersWithoutSeat > license.getSeats()) {
383+
if (occupiedSeats + usersWithoutSeat > license.getEntitlements().seats()) {
368384
throw new PaymentRequiredException("Number of effective vault users greater than or equal to the available license seats");
369385
}
370386

@@ -419,7 +435,7 @@ public Response createOrUpdate(@PathParam("vaultId") UUID vaultId, @Valid @NotNu
419435
} else {
420436
//if license is exceeded block vault creation, independent if the user is already sitting
421437
var usedSeats = effectiveVaultAccessRepo.countSeatOccupyingUsers();
422-
if (usedSeats > license.getSeats()) {
438+
if (usedSeats > license.getEntitlements().seats()) {
423439
throw new PaymentRequiredException("Number of effective vault users exceeds available license seats");
424440
}
425441
// create new vault:
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package org.cryptomator.hub.license;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
import io.quarkus.runtime.annotations.RegisterForReflection;
6+
7+
import java.time.Instant;
8+
import java.time.temporal.ChronoUnit;
9+
10+
@RegisterForReflection
11+
@JsonIgnoreProperties(ignoreUnknown = true)
12+
public record HubLicenseEntitlements(@JsonProperty("seats") long seats,
13+
@JsonProperty("showTrialHint") boolean showTrialHint,
14+
@JsonProperty("auditLogRetentionDays") long auditLogRetentionDays,
15+
@JsonProperty("iosLicense") String iosLicense,
16+
@JsonProperty("androidLicense") String androidLicense) {
17+
/**
18+
* Calculates the earliest point of time for audit log entries to still be retained.
19+
* @return {@link #auditLogRetentionDays} days in the past from now
20+
*/
21+
public Instant auditLogRetentionThreshold() {
22+
try {
23+
return Instant.now().minus(auditLogRetentionDays(), ChronoUnit.DAYS).truncatedTo(ChronoUnit.DAYS);
24+
} catch (ArithmeticException e) {
25+
return Instant.MIN;
26+
}
27+
}
28+
29+
// region Factory + Withers (should only be used in tests)
30+
31+
public static HubLicenseEntitlements create() {
32+
return new HubLicenseEntitlements(0, false, 0, null, null);
33+
}
34+
35+
public HubLicenseEntitlements withSeats(long seats) {
36+
return new HubLicenseEntitlements(seats, this.showTrialHint, this.auditLogRetentionDays, this.iosLicense, this.androidLicense);
37+
}
38+
39+
public HubLicenseEntitlements withShowTrialHint(boolean showTrialHint) {
40+
return new HubLicenseEntitlements(this.seats, showTrialHint, this.auditLogRetentionDays, this.iosLicense, this.androidLicense);
41+
}
42+
43+
public HubLicenseEntitlements withAuditLogRetentionDays(long auditLogRetentionDays) {
44+
return new HubLicenseEntitlements(this.seats, this.showTrialHint, auditLogRetentionDays, this.iosLicense, this.androidLicense);
45+
}
46+
47+
public HubLicenseEntitlements withIosLicense(String iosLicense) {
48+
return new HubLicenseEntitlements(this.seats, this.showTrialHint, this.auditLogRetentionDays, iosLicense, this.androidLicense);
49+
}
50+
51+
public HubLicenseEntitlements withAndroidLicense(String androidLicense) {
52+
return new HubLicenseEntitlements(this.seats, this.showTrialHint, this.auditLogRetentionDays, this.iosLicense, androidLicense);
53+
}
54+
55+
// endregion
56+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package org.cryptomator.hub.license;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import jakarta.ws.rs.Consumes;
6+
import jakarta.ws.rs.FormParam;
7+
import jakarta.ws.rs.GET;
8+
import jakarta.ws.rs.POST;
9+
import jakarta.ws.rs.Path;
10+
import jakarta.ws.rs.Produces;
11+
import jakarta.ws.rs.QueryParam;
12+
import jakarta.ws.rs.core.MediaType;
13+
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
14+
15+
import java.io.IOException;
16+
import java.io.UncheckedIOException;
17+
import java.util.Base64;
18+
19+
@RegisterRestClient(configKey = "license-api")
20+
public interface LicenseApi {
21+
22+
@GET
23+
@Path("/hub/challenge")
24+
@Produces(MediaType.APPLICATION_JSON)
25+
Challenge generateTrialChallenge();
26+
27+
@POST
28+
@Path("/hub/trial")
29+
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
30+
@Produces(MediaType.APPLICATION_JSON)
31+
TrialLicenseResponse generateTrialLicense(@FormParam("captcha") String captcha);
32+
33+
record Challenge(@JsonProperty("algorithm") String algorithm,
34+
@JsonProperty("challenge") String challenge,
35+
@JsonProperty("maxnumber") int maxnumber,
36+
@JsonProperty("salt") String salt,
37+
@JsonProperty("signature") String signature) {
38+
public Solution solve(int number, long took) {
39+
return new Solution(algorithm, challenge, number, salt, signature, took);
40+
}
41+
}
42+
43+
record Solution(@JsonProperty("algorithm") String algorithm,
44+
@JsonProperty("challenge") String challenge,
45+
@JsonProperty("number") int number,
46+
@JsonProperty("salt") String salt,
47+
@JsonProperty("signature") String signature,
48+
@JsonProperty("took") long took) {
49+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
50+
public String toCaptcha() {
51+
try {
52+
var serialized = OBJECT_MAPPER.writer().writeValueAsBytes(this);
53+
return Base64.getEncoder().encodeToString(serialized);
54+
} catch (IOException e) {
55+
throw new UncheckedIOException("Failed to encode captcha", e);
56+
}
57+
}
58+
}
59+
60+
record TrialLicenseResponse(@JsonProperty("hubId") String hubId,
61+
@JsonProperty("licenseKey") String licenseKey) {}
62+
63+
64+
}

0 commit comments

Comments
 (0)