Skip to content

Commit

Permalink
Merge branch 'release/1.1.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
tobihagemann committed Feb 24, 2023
2 parents b6944e9 + f950a78 commit 981c3b9
Show file tree
Hide file tree
Showing 34 changed files with 5,524 additions and 4,797 deletions.
2 changes: 1 addition & 1 deletion backend/.idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,20 @@
<modelVersion>4.0.0</modelVersion>
<groupId>org.cryptomator</groupId>
<artifactId>hub-backend</artifactId>
<version>1.0.3</version>
<version>1.1.0</version>

<properties>
<compiler-plugin.version>3.8.1</compiler-plugin.version>
<compiler-plugin.version>3.10.1</compiler-plugin.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.jdk.version>17</project.jdk.version>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<quarkus.container-image.group>cryptomator</quarkus.container-image.group>
<quarkus.container-image.name>hub</quarkus.container-image.name>
<quarkus.platform.version>2.15.3.Final</quarkus.platform.version>
<quarkus.platform.version>2.16.3.Final</quarkus.platform.version>
<quarkus.native.builder-image>quay.io/quarkus/ubi-quarkus-mandrel:22.3-java17</quarkus.native.builder-image>
<quarkus.jib.base-jvm-image>eclipse-temurin:17-jre</quarkus.jib.base-jvm-image> <!-- irrelevant for -Pnative -->
<jwt.version>4.2.2</jwt.version>
<surefire-plugin.version>3.0.0-M5</surefire-plugin.version>
<jwt.version>4.3.0</jwt.version>
<surefire-plugin.version>3.0.0-M9</surefire-plugin.version>
</properties>

<dependencyManagement>
Expand Down
19 changes: 10 additions & 9 deletions backend/src/main/java/org/cryptomator/hub/api/BillingResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
@Path("/billing")
public class BillingResource {


@Inject
LicenseHolder licenseHolder;

Expand All @@ -40,10 +39,10 @@ public class BillingResource {
@APIResponse(responseCode = "403", description = "only admins are allowed to get the billing information")
public BillingDto get() {
return Optional.ofNullable(licenseHolder.get())
.map(BillingDto::fromDecodedJwt)
.map(jwt -> BillingDto.fromDecodedJwt(jwt, licenseHolder))
.orElseGet(() -> {
var hubId = Settings.get().hubId;
return BillingDto.create(hubId);
return BillingDto.create(hubId, licenseHolder);
});
}

Expand All @@ -66,22 +65,24 @@ public Response setToken(@ValidJWS String token) {

public record BillingDto(@JsonProperty("hubId") String hubId, @JsonProperty("hasLicense") Boolean hasLicense, @JsonProperty("email") String email,
@JsonProperty("totalSeats") Integer totalSeats, @JsonProperty("remainingSeats") Integer remainingSeats,
@JsonProperty("issuedAt") Instant issuedAt, @JsonProperty("expiresAt") Instant expiresAt) {
@JsonProperty("issuedAt") Instant issuedAt, @JsonProperty("expiresAt") Instant expiresAt, @JsonProperty("managedInstance") Boolean managedInstance) {

public static BillingDto create(String hubId) {
var seats = LicenseHolder.CommunityLicenseConstants.SEATS;
public static BillingDto create(String hubId, LicenseHolder licenseHolder) {
var seats = licenseHolder.getNoLicenseSeats();
var remainingSeats = Math.max(seats - EffectiveVaultAccess.countEffectiveVaultUsers(), 0);
return new BillingDto(hubId, false, null, (int) seats, (int) remainingSeats, null, null);
var managedInstance = licenseHolder.isManagedInstance();
return new BillingDto(hubId, false, null, (int) seats, (int) remainingSeats, null, null, managedInstance);
}

public static BillingDto fromDecodedJwt(DecodedJWT jwt) {
public static BillingDto fromDecodedJwt(DecodedJWT jwt, LicenseHolder licenseHolder) {
var id = jwt.getId();
var email = jwt.getSubject();
var totalSeats = jwt.getClaim("seats").asInt();
var remainingSeats = Math.max(totalSeats - (int) EffectiveVaultAccess.countEffectiveVaultUsers(), 0);
var issuedAt = jwt.getIssuedAt().toInstant();
var expiresAt = jwt.getExpiresAt().toInstant();
return new BillingDto(id, true, email, totalSeats, remainingSeats, issuedAt, expiresAt);
var managedInstance = licenseHolder.isManagedInstance();
return new BillingDto(id, true, email, totalSeats, remainingSeats, issuedAt, expiresAt, managedInstance);
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.cryptomator.hub.entities.Settings;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;

import javax.annotation.PostConstruct;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.transaction.Transactional;
import java.time.Instant;
import java.util.Objects;
Expand All @@ -16,6 +18,10 @@
@ApplicationScoped
public class LicenseHolder {

@Inject
@ConfigProperty(name = "hub.managed-instance", defaultValue = "false")
Boolean managedInstance;

private static final Logger LOG = Logger.getLogger(LicenseHolder.class);
private final LicenseValidator licenseValidator;
private DecodedJWT license;
Expand Down Expand Up @@ -69,26 +75,45 @@ public DecodedJWT get() {
public boolean isExpired() {
return Optional.ofNullable(license) //
.map(l -> l.getExpiresAt().toInstant().isBefore(Instant.now())) //
.orElse(CommunityLicenseConstants.IS_EXPIRED);
.orElse(false);
}

/**
* Gets the number of available seats of the license
*
* @return Number of available seats, if license is not null. Otherwise {@value CommunityLicenseConstants#SEATS}.
* @return Number of available seats, if license is not null. Otherwise {@value SelfHostedNoLicenseConstants#SEATS}.
*/
public long getAvailableSeats() {
return Optional.ofNullable(license) //
.map(l -> l.getClaim("seats")) //
.map(Claim::asLong) //
.orElse(CommunityLicenseConstants.SEATS);
.orElseGet(this::getNoLicenseSeats);
}

public long getNoLicenseSeats() {
if (!managedInstance) {
return SelfHostedNoLicenseConstants.SEATS;
} else {
return ManagedInstanceNoLicenseConstants.SEATS;
}
}

public boolean isManagedInstance() {
return managedInstance;
}

public static class CommunityLicenseConstants {
public static class SelfHostedNoLicenseConstants {
public static final long SEATS = 5;
static final boolean IS_EXPIRED = false;

private CommunityLicenseConstants() {
private SelfHostedNoLicenseConstants() {
throw new IllegalStateException("Utility class");
}
}

public static class ManagedInstanceNoLicenseConstants {
public static final long SEATS = 0;

private ManagedInstanceNoLicenseConstants() {
throw new IllegalStateException("Utility class");
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import java.security.interfaces.ECPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Objects;

@ApplicationScoped
Expand All @@ -32,7 +33,8 @@ public class LicenseValidator {

public LicenseValidator() {
var algorithm = Algorithm.ECDSA512(decodePublicKey(LICENSE_PUBLIC_KEY), null);
this.verifier = JWT.require(algorithm).build();
var leeway = Instant.now().getEpochSecond(); // this will make sure to accept tokens that expired in the past (beginning from 1970)
this.verifier = JWT.require(algorithm).acceptExpiresAt(leeway).build();
}

private static ECPublicKey decodePublicKey(String pemEncodedPublicKey) {
Expand Down
5 changes: 4 additions & 1 deletion backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ hub.keycloak.public-url=http://localhost:8180
hub.keycloak.local-url=http://localhost:8180
hub.keycloak.realm=cryptomator

hub.managed-instance=false

quarkus.resteasy-reactive.path=/api
%test.quarkus.resteasy-reactive.path=/

Expand All @@ -30,7 +32,7 @@ hub.keycloak.oidc.cryptomator-client-id=cryptomator
%dev.quarkus.keycloak.devservices.realm-name=cryptomator
%dev.quarkus.keycloak.devservices.port=8180
%dev.quarkus.keycloak.devservices.service-name=quarkus-cryptomator-hub
%dev.quarkus.keycloak.devservices.image-name=ghcr.io/cryptomator/keycloak:20.0.3
%dev.quarkus.keycloak.devservices.image-name=ghcr.io/cryptomator/keycloak:21.0.0
%dev.quarkus.oidc.devui.grant.type=code
# OIDC will be mocked during unit tests. Use fake auth url to prevent dev services to start:
%test.quarkus.oidc.auth-server-url=http://localhost:43210/dev/null
Expand Down Expand Up @@ -75,6 +77,7 @@ quarkus.flyway.locations=classpath:org/cryptomator/hub/flyway

# Allow cross-origin requests in DEV profile
%dev.quarkus.http.cors=true
%dev.quarkus.http.cors.origins=http://localhost:3000,http//localhost:8080

%test.quarkus.application.version=TEST_VERSION_3000

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package org.cryptomator.hub.api;

import com.radcortez.flyway.test.annotation.DataSource;
import com.radcortez.flyway.test.annotation.FlywayTest;
import io.agroal.api.AgroalDataSource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.quarkus.test.security.TestSecurity;
import io.quarkus.test.security.oidc.Claim;
import io.quarkus.test.security.oidc.OidcSecurity;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import javax.inject.Inject;
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
@FlywayTest(value = @DataSource(url = "jdbc:h2:mem:test"), additionalLocations = {"classpath:org/cryptomator/hub/flyway"})
@DisplayName("Resource /billing managed instance")
@TestSecurity(user = "Admin", roles = {"admin"})
@OidcSecurity(claims = {
@Claim(key = "sub", value = "admin")
})
@TestProfile(BillingResourceManagedInstanceTest.ManagedInstanceTestProfile.class)
public class BillingResourceManagedInstanceTest {

@Inject
AgroalDataSource dataSource;

@BeforeAll
public static void beforeAll() {
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
}

public static class ManagedInstanceTestProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of("hub.managed-instance", "true");
}
}

@Test
@DisplayName("GET /billing returns 401 with empty license managed instance")
public void testGetEmptyManagedInstance() throws SQLException {
try (var s = dataSource.getConnection().createStatement()) {
s.execute("""
UPDATE "settings"
SET "hub_id" = '42', "license_key" = null
WHERE "id" = 0;
""");
}

when().get("/billing")
.then().statusCode(200)
.body("hubId", is("42"))
.body("hasLicense", is(false))
.body("email", nullValue())
.body("totalSeats", is(0))
.body("remainingSeats", is(0))
.body("issuedAt", nullValue())
.body("expiresAt", nullValue());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ public class AsAdmin {

@Test
@Order(1)
@DisplayName("GET /billing returns 200 with empty license")
public void testGetEmpty() throws SQLException {
@DisplayName("GET /billing returns 200 with empty license self-hosted")
public void testGetEmptySelfHosted() throws SQLException {
try (var s = dataSource.getConnection().createStatement()) {
s.execute("""
UPDATE "settings"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@
import com.auth0.jwt.exceptions.InvalidClaimException;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class LicenseValidatorTest {

private static final String VALID_TOKEN = "eyJhbGciOiJFUzUxMiJ9.eyJqdGkiOiI0MiIsImlhdCI6MTY0ODA0OTM2MCwiaXNzIjoiU2t5bWF0aWMiLCJhdWQiOiJDcnlwdG9tYXRvciBIdWIiLCJzdWIiOiJodWJAY3J5cHRvbWF0b3Iub3JnIiwic2VhdHMiOjUsImV4cCI6MjUzNDAyMjE0NDAwLCJyZWZyZXNoVXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2h1Yi9zdWJzY3JpcHRpb24_aHViX2lkPTQyIn0.AKyoZ0WQ8xhs8vPymWPHCsc6ch6pZpfxBcrF5QjVLSQVnYz2s5QF3nnkwn4AGR7V14TuhkJMZLUZxMdQAYLyL95sAV2Fu0E4-e1v3IVKlNKtze89eqYvEs6Ak9jWjtecOgPWNWjz2itI4MfJBDmbFtTnehOtqRqUdsDoC9NFik2C7tHm";
private static final String EXPIRED_TOKEN = "eyJhbGciOiJFUzUxMiJ9.eyJqdGkiOiI0MiIsImlhdCI6MTY0ODA1MTA0MCwiaXNzIjoiU2t5bWF0aWMiLCJhdWQiOiJDcnlwdG9tYXRvciBIdWIiLCJzdWIiOiJodWJAY3J5cHRvbWF0b3Iub3JnIiwic2VhdHMiOjUsImV4cCI6LTYyMTY3MjE5MjAwLCJyZWZyZXNoVXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2h1Yi9zdWJzY3JpcHRpb24_aHViX2lkPTQyIn0.AK9Du5MNsyVgOzicgi2S7ECxxqZPBLP8cFByAAZ7_y96NEvrwOiR8NNmlZlfvebMfckaYUEg-nf3BAd1JHAxur1UADyYbKSLoMs4B69SkbKW0drjfY9RjFUhO_w6sS4gg39_X_IhbwW6wRkGFoqGRI0juaCPViQqV5WFIcj7RbuCcNJ6";
private static final String EXPIRED_TOKEN = "eyJhbGciOiJFUzUxMiJ9.eyJqdGkiOiI0MiIsImlhdCI6MTY3NzA4MzI1OSwiaXNzIjoiU2t5bWF0aWMiLCJhdWQiOiJDcnlwdG9tYXRvciBIdWIiLCJzdWIiOiJodWJAY3J5cHRvbWF0b3Iub3JnIiwic2VhdHMiOjUsImV4cCI6OTQ2Njg0ODAwLCJyZWZyZXNoVXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2h1Yi9zdWJzY3JpcHRpb24_aHViX2lkPTQyIn0.APQnWig9ZyT6_xRviPVs3YPTaP1w_YXTpWULgvsUpCGmGQwEmT6nl0x2jNB_jkQi93E7tr9WvipvX5DkXUOYJP3OAJjzPdN7rTX2tnXTKO8irshkcqmvt79v1E4k50YLkwP-1NIwiO_ltp5sezhLbzOVPXRag6mQfc0KvS6PiZTYGYQh";
private static final String TOKEN_WITH_INVALID_SIGNATURE = "eyJhbGciOiJFUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.AbVUinMiT3J_03je8WTOIl-VdggzvoFgnOsdouAs-DLOtQzau9valrq-S6pETyi9Q18HH-EuwX49Q7m3KC0GuNBJAc9Tksulgsdq8GqwIqZqDKmG7hNmDzaQG1Dpdezn2qzv-otf3ZZe-qNOXUMRImGekfQFIuH_MjD2e8RZyww6lbZk";
private static final String MALFORMED_TOKEN = "hello world";

Expand Down Expand Up @@ -42,9 +41,8 @@ public void testValidateValidTokenWithMismatchingHubId() {
@Test
@DisplayName("validate expired token")
public void testValidateExpiredToken() {
Assertions.assertThrows(TokenExpiredException.class, () -> {
validator.validate(EXPIRED_TOKEN, "42");
});
// this should not throw an exception and return a JWT with an expired date
validator.validate(EXPIRED_TOKEN, "42");
}

@Test
Expand Down
2 changes: 1 addition & 1 deletion frontend/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ module.exports = {
'semi': ['error', 'always'],
'space-infix-ops': 'error',
'@typescript-eslint/indent': ['error', 2],
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-unused-vars': 'off', // is checked by noUnusedLocals in tsconfig.json
'no-undef': 'off' // types checked by typescript already
}
};
Loading

0 comments on commit 981c3b9

Please sign in to comment.