Skip to content

Commit eb99ab2

Browse files
Support session-based license refresh, revert #/?
1 parent 06f46b5 commit eb99ab2

9 files changed

Lines changed: 300 additions & 58 deletions

File tree

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import com.fasterxml.jackson.annotation.JsonProperty;
44
import jakarta.annotation.security.RolesAllowed;
55
import jakarta.inject.Inject;
6+
import jakarta.ws.rs.Consumes;
7+
import jakarta.ws.rs.FormParam;
68
import jakarta.ws.rs.GET;
79
import jakarta.ws.rs.InternalServerErrorException;
810
import jakarta.ws.rs.POST;
@@ -14,9 +16,10 @@
1416
import org.cryptomator.hub.license.LicenseHolder;
1517
import org.eclipse.microprofile.openapi.annotations.Operation;
1618
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
19+
import org.jspecify.annotations.Nullable;
1720

18-
import java.io.IOException;
1921
import java.time.Instant;
22+
import java.util.UUID;
2023

2124
@Path("/license")
2225
public class LicenseResource {
@@ -55,18 +58,23 @@ public static LicenseUserInfoDto create(LicenseHolder licenseHolder, int usedSea
5558

5659
@POST
5760
@Path("/refresh")
61+
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
5862
@RolesAllowed("admin")
5963
@Operation(summary = "Refresh license information", description = "Refreshes the license information from the license server.")
6064
@APIResponse(responseCode = "204", description = "License information refreshed")
65+
@APIResponse(responseCode = "404", description = "Session not found")
6166
@APIResponse(responseCode = "500", description = "License refresh failed")
62-
public Response refresh() {
67+
public Response refresh(@FormParam("session") @Nullable UUID session) {
6368
try {
64-
licenseHolder.refreshLicense();
65-
} catch (IOException e) {
69+
if (session == null) {
70+
licenseHolder.refreshLicense();
71+
} else {
72+
licenseHolder.refreshLicense(session);
73+
}
74+
} catch (LicenseHolder.LicenseRefreshFailedException e) {
6675
throw new InternalServerErrorException("License refresh failed", e);
6776
}
6877
return Response.noContent().build();
6978
}
7079

71-
7280
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package org.cryptomator.hub.license;
2+
3+
import jakarta.ws.rs.NotFoundException;
4+
import jakarta.ws.rs.ServerErrorException;
5+
import jakarta.ws.rs.WebApplicationException;
6+
import jakarta.ws.rs.core.Response;
7+
import org.eclipse.microprofile.rest.client.ext.ResponseExceptionMapper;
8+
9+
/**
10+
* Translates certain http status codes to specific exception types.
11+
*/
12+
public class ExceptionMapper implements ResponseExceptionMapper<WebApplicationException> {
13+
14+
@Override
15+
public WebApplicationException toThrowable(Response response) {
16+
return switch (response.getStatus()) {
17+
case 404 -> new NotFoundException();
18+
default -> new ServerErrorException("Received unexpected http status code: " + response.getStatus(), Response.Status.BAD_GATEWAY);
19+
};
20+
}
21+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@
99
import jakarta.ws.rs.POST;
1010
import jakarta.ws.rs.Path;
1111
import jakarta.ws.rs.Produces;
12+
import jakarta.ws.rs.QueryParam;
1213
import jakarta.ws.rs.core.MediaType;
14+
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
1315
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
1416

1517
import java.io.IOException;
1618
import java.io.UncheckedIOException;
1719
import java.util.Base64;
20+
import java.util.UUID;
1821

1922
@RegisterRestClient(configKey = "license-api")
23+
@RegisterProvider(ExceptionMapper.class)
2024
public interface LicenseApi {
2125

2226
@GET
@@ -41,6 +45,11 @@ public interface LicenseApi {
4145
@Produces(MediaType.TEXT_PLAIN)
4246
String refreshLicense(@FormParam("token") String licenseKey, @FormParam("captcha") String captcha);
4347

48+
@GET
49+
@Path("/hub")
50+
@Produces(MediaType.TEXT_PLAIN)
51+
String getLicense(@QueryParam("session") UUID sessionId);
52+
4453
record Challenge(@JsonProperty("algorithm") String algorithm,
4554
@JsonProperty("challenge") String challenge,
4655
@JsonProperty("maxnumber") int maxnumber,

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

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import java.util.Base64;
3434
import java.util.HexFormat;
3535
import java.util.Optional;
36+
import java.util.UUID;
3637

3738
@ApplicationScoped
3839
public class LicenseHolder {
@@ -83,7 +84,7 @@ void init() {
8384
LOG.debug("License was issued more than 5 minutes ago. Attempting a refresh to ensure we have the latest license information from the license server.");
8485
try {
8586
refreshLicense();
86-
} catch (IOException e) {
87+
} catch (LicenseRefreshFailedException e) {
8788
LOG.error("Failed to refresh license during startup.", e);
8889
}
8990
}
@@ -210,31 +211,31 @@ void scheduleLicenseRefresh() {
210211
try {
211212
randomSleeper.sleep(0, 59, ChronoUnit.MINUTES); // add random sleep to reduce infrastructure load
212213
refreshLicense();
213-
} catch (IOException e) {
214+
} catch (LicenseRefreshFailedException e) {
214215
LOG.error("Scheduled license refresh failed.", e);
215216
} catch (InterruptedException e) {
216217
Thread.currentThread().interrupt();
217218
LOG.warn("Scheduled license refresh was interrupted.", e);
218219
}
219220
}
220221

221-
public void refreshLicense() throws IOException {
222+
public void refreshLicense(UUID session) throws LicenseRefreshFailedException, WebApplicationException {
223+
var refreshedLicense = licenseApi.getLicense(session);
224+
validateAndSet(refreshedLicense);
225+
}
226+
227+
public void refreshLicense() throws LicenseRefreshFailedException {
222228
var refreshUrl = getLicenseRefreshUri();
223-
final String refreshedLicense;
224-
try {
225-
refreshedLicense = requestLicenseRefresh(refreshUrl, get().getToken());
226-
} catch (LicenseRefreshFailedException e) {
227-
LOG.errorv("Failed to refresh license token. Request to {0} was answered with response code {1,number,integer}", refreshUrl, e.statusCode);
228-
throw new IOException("Failed to refresh license token.", e);
229-
} catch (InterruptedException _) {
230-
Thread.currentThread().interrupt();
231-
throw new InterruptedIOException("License refresh was interrupted");
232-
}
229+
var refreshedLicense = requestLicenseRefresh(refreshUrl, get().getToken());
230+
validateAndSet(refreshedLicense);
231+
}
232+
233+
private void validateAndSet(String refreshedLicense) throws LicenseRefreshFailedException {
233234
try {
234235
set(refreshedLicense);
235236
} catch (JWTVerificationException e) {
236237
LOG.errorv(e, "Failed to refresh license token. Refreshed token is invalid: {0}", refreshedLicense);
237-
throw new IOException("Invalid new license token.", e);
238+
throw new LicenseRefreshFailedException("Invalid new license token.", e);
238239
}
239240
}
240241

@@ -251,8 +252,14 @@ private URI getLicenseRefreshUri() {
251252
}
252253

253254
//visible for testing
254-
String requestLicenseRefresh(URI refreshUrl, String licenseToken) throws InterruptedException, IOException, LicenseRefreshFailedException {
255+
String requestLicenseRefresh(URI refreshUrl, String licenseToken) throws LicenseRefreshFailedException {
255256
var solution = solveChallenge();
257+
// TODO: do we eventually want to migrate away from refreshUrl and use hard-coded api? using it in other places already anyway.
258+
// try {
259+
// return licenseApi.refreshLicense(licenseToken, solution.toCaptcha());
260+
// } catch (WebApplicationException e) {
261+
// throw new LicenseRefreshFailedException(e.getResponse().getStatus());
262+
// }
256263
try (var client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build()) {
257264
var body = "token=" + URLEncoder.encode(licenseToken, StandardCharsets.UTF_8)
258265
+ "&captcha=" + URLEncoder.encode(solution.toCaptcha(), StandardCharsets.UTF_8);
@@ -266,8 +273,13 @@ String requestLicenseRefresh(URI refreshUrl, String licenseToken) throws Interru
266273
if (response.statusCode() == 200 && !response.body().isEmpty()) {
267274
return response.body();
268275
} else {
269-
throw new LicenseRefreshFailedException(response.statusCode(), body);
276+
throw new LicenseRefreshFailedException("License endpoint responded with status code " + response.statusCode());
270277
}
278+
} catch (IOException e) {
279+
throw new LicenseRefreshFailedException("I/O error", e);
280+
} catch (InterruptedException e) {
281+
Thread.currentThread().interrupt();
282+
throw new LicenseRefreshFailedException("License refresh was interrupted", e);
271283
}
272284
}
273285

@@ -306,13 +318,18 @@ public boolean isManagedInstance() {
306318
return managedInstance;
307319
}
308320

309-
static class LicenseRefreshFailedException extends RuntimeException {
310-
final int statusCode;
311-
final String body;
321+
public static class LicenseRefreshFailedException extends Exception {
322+
323+
LicenseRefreshFailedException(String message, Throwable cause) {
324+
super(message, cause);
325+
}
326+
327+
LicenseRefreshFailedException(String message) {
328+
super(message);
329+
}
312330

313-
LicenseRefreshFailedException(int statusCode, String body) {
314-
this.statusCode = statusCode;
315-
this.body = body;
331+
LicenseRefreshFailedException(Throwable cause) {
332+
super(cause);
316333
}
317334
}
318335

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package org.cryptomator.hub.api;
2+
3+
import io.quarkus.test.InjectMock;
4+
import io.quarkus.test.junit.QuarkusTest;
5+
import io.quarkus.test.security.TestSecurity;
6+
import io.quarkus.test.security.oidc.Claim;
7+
import io.quarkus.test.security.oidc.OidcSecurity;
8+
import io.restassured.RestAssured;
9+
import io.restassured.http.ContentType;
10+
import org.cryptomator.hub.entities.EffectiveVaultAccess;
11+
import org.cryptomator.hub.license.LicenseHolder;
12+
import org.junit.jupiter.api.BeforeAll;
13+
import org.junit.jupiter.api.DisplayName;
14+
import org.junit.jupiter.api.Nested;
15+
import org.junit.jupiter.api.Test;
16+
import org.mockito.Mockito;
17+
18+
import java.util.UUID;
19+
20+
import static io.restassured.RestAssured.given;
21+
import static io.restassured.RestAssured.when;
22+
23+
@QuarkusTest
24+
@DisplayName("Resource /license")
25+
class LicenseResourceTest {
26+
27+
private static final UUID SESSION = UUID.fromString("11111111-2222-3333-4444-555555555555");
28+
29+
@InjectMock
30+
LicenseHolder licenseHolder;
31+
32+
@InjectMock
33+
EffectiveVaultAccess.Repository effectiveVaultAccessRepo;
34+
35+
@BeforeAll
36+
static void beforeAll() {
37+
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
38+
}
39+
40+
@Nested
41+
@DisplayName("As admin")
42+
@TestSecurity(user = "Admin", roles = {"admin"})
43+
@OidcSecurity(claims = {
44+
@Claim(key = "sub", value = "admin")
45+
})
46+
class AsAdmin {
47+
48+
@Test
49+
@DisplayName("POST /license/refresh returns 204 and refreshes from the refreshUrl")
50+
void testRefresh() throws LicenseHolder.LicenseRefreshFailedException {
51+
when().post("/license/refresh")
52+
.then().statusCode(204);
53+
54+
Mockito.verify(licenseHolder).refreshLicense();
55+
}
56+
57+
@Test
58+
@DisplayName("POST /license/refresh returns 500 if the refresh fails")
59+
void testRefreshFailure() throws LicenseHolder.LicenseRefreshFailedException {
60+
Mockito.doThrow(LicenseHolder.LicenseRefreshFailedException.class).when(licenseHolder).refreshLicense();
61+
62+
when().post("/license/refresh")
63+
.then().statusCode(500);
64+
}
65+
66+
@Test
67+
@DisplayName("POST /license/refresh with session returns 204 and refreshes for that session")
68+
void testRefreshSession() throws LicenseHolder.LicenseRefreshFailedException {
69+
given().contentType(ContentType.URLENC).formParam("session", SESSION.toString())
70+
.when().post("/license/refresh")
71+
.then().statusCode(204);
72+
73+
Mockito.verify(licenseHolder).refreshLicense(SESSION);
74+
}
75+
76+
@Test
77+
@DisplayName("POST /license/refresh with session returns 500 if the refresh fails")
78+
void testRefreshSessionFailure() throws LicenseHolder.LicenseRefreshFailedException {
79+
Mockito.doThrow(LicenseHolder.LicenseRefreshFailedException.class).when(licenseHolder).refreshLicense(SESSION);
80+
81+
given().contentType(ContentType.URLENC).formParam("session", SESSION.toString())
82+
.when().post("/license/refresh")
83+
.then().statusCode(500);
84+
}
85+
86+
}
87+
88+
@Nested
89+
@DisplayName("As any other role")
90+
@TestSecurity(user = "User Name 1", roles = {"user"})
91+
@OidcSecurity(claims = {
92+
@Claim(key = "sub", value = "user1")
93+
})
94+
class AsAnyOtherRole {
95+
96+
@Test
97+
@DisplayName("POST /license/refresh returns 403 Forbidden")
98+
void testRefresh() {
99+
when().post("/license/refresh")
100+
.then().statusCode(403);
101+
}
102+
103+
@Test
104+
@DisplayName("POST /license/refresh with session returns 403 Forbidden")
105+
void testRefreshSession() {
106+
given().contentType(ContentType.URLENC).formParam("session", SESSION.toString())
107+
.when().post("/license/refresh")
108+
.then().statusCode(403);
109+
}
110+
111+
}
112+
113+
@Nested
114+
@DisplayName("As unauthenticated user")
115+
class AsAnonymous {
116+
117+
@Test
118+
@DisplayName("POST /license/refresh returns 401 Unauthorized")
119+
void testRefresh() {
120+
when().post("/license/refresh")
121+
.then().statusCode(401);
122+
}
123+
124+
@Test
125+
@DisplayName("POST /license/refresh with session returns 401 Unauthorized")
126+
void testRefreshSession() {
127+
given().contentType(ContentType.URLENC).formParam("session", SESSION.toString())
128+
.when().post("/license/refresh")
129+
.then().statusCode(401);
130+
}
131+
132+
}
133+
134+
}

0 commit comments

Comments
 (0)