Skip to content

Commit

Permalink
Merge branch 'develop' into feature/provenance
Browse files Browse the repository at this point in the history
# Conflicts:
#	backend/pom.xml
  • Loading branch information
overheadhunter committed Feb 21, 2025
2 parents 625d3cd + 76e7431 commit d53abc6
Show file tree
Hide file tree
Showing 23 changed files with 654 additions and 348 deletions.
11 changes: 7 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- WoT: Admins can adjust WoT parameters (#297)
- Permission to create new vaults can now be controlled via the `create-vaults` role in Keycloak (#206)
- Preserver user locale setting (#313)
- New log event entries: UserAccountReset, UserKeysChange and UserSetupCodeChange (#310)
- Audit log filter by event type (#312)
- Show last IP address and last vault access timestamp of devices in user profile (#320)
- Italian, Korean, Dutch and Portuguese translation
- Audit log filter by event type

### Changed

- Updated Keycloak to 25.0.6
- Updated Keycloak to 26.1.2
- Updated to Java 21 (#272)
- Updated to Quarkus 3.8.x LTS (#272)
- Updated to tailwindcss 4
- Updated to Quarkus 3.15.3 LTS
- Updated to Tailwind CSS 4
- Updated to Vite 6
- Reduced number of transitive dependencies
- Bumped build time dependencies
Expand All @@ -32,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Switched to JWK thumbprint format in user profile
- Switched to Repository Pattern (#273)
- Redesigned Admin Panel (#308)
- Enhanced audit log VaultKeyRetrievedEvent, contains now IP address and device ID (#320)

### Fixed

Expand Down
4 changes: 2 additions & 2 deletions backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.jdk.version>21</project.jdk.version>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<quarkus.platform.version>3.15.2</quarkus.platform.version>
<jwt.version>4.4.0</jwt.version>
<quarkus.platform.version>3.15.3</quarkus.platform.version>
<jwt.version>4.5.0</jwt.version>
<compiler-plugin.version>3.13.0</compiler-plugin.version>
<dependency-plugin.version>3.8.1</dependency-plugin.version>
<surefire-plugin.version>3.5.2</surefire-plugin.version>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ static AuditEventDto fromEntity(AuditEvent entity) {
case VaultCreatedEvent evt -> new VaultCreatedEventDto(evt.getId(), evt.getTimestamp(), VaultCreatedEvent.TYPE, evt.getCreatedBy(), evt.getVaultId(), evt.getVaultName(), evt.getVaultDescription());
case VaultUpdatedEvent evt -> new VaultUpdatedEventDto(evt.getId(), evt.getTimestamp(), VaultUpdatedEvent.TYPE, evt.getUpdatedBy(), evt.getVaultId(), evt.getVaultName(), evt.getVaultDescription(), evt.isVaultArchived());
case VaultAccessGrantedEvent evt -> new VaultAccessGrantedEventDto(evt.getId(), evt.getTimestamp(), VaultAccessGrantedEvent.TYPE, evt.getGrantedBy(), evt.getVaultId(), evt.getAuthorityId());
case VaultKeyRetrievedEvent evt -> new VaultKeyRetrievedEventDto(evt.getId(), evt.getTimestamp(), VaultKeyRetrievedEvent.TYPE, evt.getRetrievedBy(), evt.getVaultId(), evt.getResult());
case VaultKeyRetrievedEvent evt -> new VaultKeyRetrievedEventDto(evt.getId(), evt.getTimestamp(), VaultKeyRetrievedEvent.TYPE, evt.getRetrievedBy(), evt.getVaultId(), evt.getResult(), evt.getIpAddress(), evt.getDeviceId());
case VaultMemberAddedEvent evt -> new VaultMemberAddedEventDto(evt.getId(), evt.getTimestamp(), VaultMemberAddedEvent.TYPE, evt.getAddedBy(), evt.getVaultId(), evt.getAuthorityId(), evt.getRole());
case VaultMemberRemovedEvent evt -> new VaultMemberRemovedEventDto(evt.getId(), evt.getTimestamp(), VaultMemberRemovedEvent.TYPE, evt.getRemovedBy(), evt.getVaultId(), evt.getAuthorityId());
case VaultMemberUpdatedEvent evt -> new VaultMemberUpdatedEventDto(evt.getId(), evt.getTimestamp(), VaultMemberUpdatedEvent.TYPE, evt.getUpdatedBy(), evt.getVaultId(), evt.getAuthorityId(), evt.getRole());
Expand Down Expand Up @@ -177,7 +177,7 @@ record VaultAccessGrantedEventDto(long id, Instant timestamp, String type, @Json
}

record VaultKeyRetrievedEventDto(long id, Instant timestamp, String type, @JsonProperty("retrievedBy") String retrievedBy, @JsonProperty("vaultId") UUID vaultId,
@JsonProperty("result") VaultKeyRetrievedEvent.Result result) implements AuditEventDto {
@JsonProperty("result") VaultKeyRetrievedEvent.Result result, @JsonProperty("ipAddress") String ipAddress, @JsonProperty("deviceId") String deviceId) implements AuditEventDto {
}

record VaultMemberAddedEventDto(long id, Instant timestamp, String type, @JsonProperty("addedBy") String addedBy, @JsonProperty("vaultId") UUID vaultId, @JsonProperty("authorityId") String authorityId,
Expand Down
14 changes: 12 additions & 2 deletions backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.cryptomator.hub.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.annotation.Nullable;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.persistence.NoResultException;
Expand All @@ -25,6 +26,7 @@
import org.cryptomator.hub.entities.LegacyDevice;
import org.cryptomator.hub.entities.User;
import org.cryptomator.hub.entities.events.EventLogger;
import org.cryptomator.hub.entities.events.VaultKeyRetrievedEvent;
import org.cryptomator.hub.validation.NoHtmlOrScriptChars;
import org.cryptomator.hub.validation.OnlyBase64Chars;
import org.cryptomator.hub.validation.ValidId;
Expand Down Expand Up @@ -174,10 +176,18 @@ public record DeviceDto(@JsonProperty("id") @ValidId String id,
@JsonProperty("publicKey") @NotNull @OnlyBase64Chars String publicKey,
@JsonProperty("userPrivateKey") @NotNull @ValidJWE String userPrivateKeys, // singular name for history reasons (don't break client compatibility)
@JsonProperty("owner") @ValidId String ownerId,
@JsonProperty("creationTime") Instant creationTime) {
@JsonProperty("creationTime") Instant creationTime,
@JsonProperty("lastIpAddress") String lastIpAddress,
@JsonProperty("lastAccessTime") Instant lastAccessTime) {

public static DeviceDto fromEntity(Device entity) {
return new DeviceDto(entity.getId(), entity.getName(), entity.getType(), entity.getPublickey(), entity.getUserPrivateKeys(), entity.getOwner().getId(), entity.getCreationTime().truncatedTo(ChronoUnit.MILLIS));
return new DeviceDto(entity.getId(), entity.getName(), entity.getType(), entity.getPublickey(), entity.getUserPrivateKeys(), entity.getOwner().getId(), entity.getCreationTime().truncatedTo(ChronoUnit.MILLIS), null, null);
}

public static DeviceDto fromEntity(Device d, @Nullable VaultKeyRetrievedEvent event) {
var lastIpAddress = (event != null) ? event.getIpAddress() : null;
var lastAccessTime = (event != null) ? event.getTimestamp() : null;
return new DeviceResource.DeviceDto(d.getId(), d.getName(), d.getType(), d.getPublickey(), d.getUserPrivateKeys(), d.getOwner().getId(), d.getCreationTime().truncatedTo(ChronoUnit.MILLIS), lastIpAddress, lastAccessTime);
}

}
Expand Down
21 changes: 17 additions & 4 deletions backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
import org.cryptomator.hub.entities.User;
import org.cryptomator.hub.entities.Vault;
import org.cryptomator.hub.entities.WotEntry;
import org.cryptomator.hub.entities.events.AuditEvent;
import org.cryptomator.hub.entities.events.EventLogger;
import org.cryptomator.hub.entities.events.VaultKeyRetrievedEvent;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
Expand Down Expand Up @@ -58,6 +60,8 @@ public class UsersResource {
WotEntry.Repository wotRepo;
@Inject
EffectiveWot.Repository effectiveWotRepo;
@Inject
AuditEvent.Repository auditEventRepo;

@Inject
JsonWebToken jwt;
Expand Down Expand Up @@ -156,11 +160,20 @@ public Response updateMyAccessTokens(@NotNull Map<UUID, String> tokens) {
@Operation(summary = "get the logged-in user")
@APIResponse(responseCode = "200", description = "returns the current user")
@APIResponse(responseCode = "404", description = "no user matching the subject of the JWT passed as Bearer Token")
public UserDto getMe(@QueryParam("withDevices") boolean withDevices) {
public UserDto getMe(@QueryParam("withDevices") boolean withDevices, @QueryParam("withLastAccess") boolean withLastAccess) {
User user = userRepo.findById(jwt.getSubject());
Function<Device, DeviceResource.DeviceDto> mapDevices = d -> new DeviceResource.DeviceDto(d.getId(), d.getName(), d.getType(), d.getPublickey(), d.getUserPrivateKeys(), d.getOwner().getId(), d.getCreationTime().truncatedTo(ChronoUnit.MILLIS));
var devices = withDevices ? user.devices.stream().map(mapDevices).collect(Collectors.toSet()) : Set.<DeviceResource.DeviceDto>of();
return new UserDto(user.getId(), user.getName(), user.getPictureUrl(), user.getEmail(), user.getLanguage(), devices, user.getEcdhPublicKey(), user.getEcdsaPublicKey(), user.getPrivateKeys(), user.getSetupCode());
Set<DeviceResource.DeviceDto> deviceDtos;
if (withLastAccess) {
var devices = user.devices.stream().collect(Collectors.toMap(Device::getId, Function.identity()));
var events = auditEventRepo.findLastVaultKeyRetrieve(devices.keySet()).collect(Collectors.toMap(VaultKeyRetrievedEvent::getDeviceId, Function.identity()));
deviceDtos = devices.values().stream().map(d -> {
var event = events.get(d.getId());
return DeviceResource.DeviceDto.fromEntity(d, event);
}).collect(Collectors.toSet());
} else {
deviceDtos = withDevices ? user.devices.stream().map(DeviceResource.DeviceDto::fromEntity).collect(Collectors.toSet()) : Set.of();
}
return new UserDto(user.getId(), user.getName(), user.getPictureUrl(), user.getEmail(), user.getLanguage(), deviceDtos, user.getEcdhPublicKey(), user.getEcdsaPublicKey(), user.getPrivateKeys(), user.getSetupCode());
}

@POST
Expand Down
20 changes: 13 additions & 7 deletions backend/src/main/java/org/cryptomator/hub/api/VaultResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.quarkus.security.identity.SecurityIdentity;
import io.vertx.core.http.HttpServerRequest;
import jakarta.annotation.Nullable;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
Expand All @@ -29,6 +30,7 @@
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.cryptomator.hub.entities.AccessToken;
Expand Down Expand Up @@ -96,6 +98,9 @@ public class VaultResource {
@Inject
LicenseHolder license;

@Context
HttpServerRequest request;

@GET
@Path("/accessible")
@RolesAllowed("user")
Expand Down Expand Up @@ -276,15 +281,15 @@ public Response legacyUnlock(@PathParam("vaultId") UUID vaultId, @PathParam("dev
if (accessTokenSeats > license.getSeats()) {
throw new PaymentRequiredException("Number of effective vault users exceeds available license seats");
}

var ipAddress = request.remoteAddress().hostAddress();
try {
var access = legacyAccessTokenRepo.unlock(vaultId, deviceId, jwt.getSubject());
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.SUCCESS);
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
return Response.ok(access.getJwe()).header(subscriptionStateHeaderName, subscriptionStateHeaderValue).build();
} catch (NoResultException e){
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.UNAUTHORIZED);
} catch (NoResultException e) {
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.UNAUTHORIZED, ipAddress, deviceId);
throw new ForbiddenException("Access to this device not granted.");
}
}
Expand Down Expand Up @@ -317,17 +322,18 @@ public Response unlock(@PathParam("vaultId") UUID vaultId, @QueryParam("evenIfAr
if (user.getEcdhPublicKey() == null) {
throw new ActionRequiredException("User account not initialized.");
}

var ipAddress = request.remoteAddress().hostAddress();
var deviceId = request.getHeader("Hub-Device-ID");
var access = accessTokenRepo.unlock(vaultId, jwt.getSubject());
if (access != null) {
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.SUCCESS);
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
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.");
} else {
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.UNAUTHORIZED);
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.UNAUTHORIZED, ipAddress, deviceId);
throw new ForbiddenException("Access to this vault not granted.");
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;

@Entity
Expand All @@ -45,6 +46,17 @@
AND (:allTypes = true OR ae.type IN :types)
ORDER BY ae.id ASC
""")
@NamedQuery(name = "AuditEvent.lastVaultKeyRetrieve",
query = """
SELECT e1
FROM VaultKeyRetrievedEvent e1
WHERE e1.deviceId IN (:deviceIds)
AND e1.timestamp = (
SELECT MAX(e2.timestamp)
FROM VaultKeyRetrievedEvent e2
WHERE e2.deviceId = e1.deviceId
)
""")
@SequenceGenerator(name = "audit_event_id_seq", sequenceName = "audit_event_id_seq", allocationSize = 1)
public class AuditEvent {

Expand Down Expand Up @@ -127,5 +139,9 @@ public Stream<AuditEvent> findAllInPeriod(Instant startDate, Instant endDate, Li
query.page(0, pageSize);
return query.stream();
}

public Stream<VaultKeyRetrievedEvent> findLastVaultKeyRetrieve(Set<String> deviceIds) {
return find("#AuditEvent.lastVaultKeyRetrieve", Parameters.with("deviceIds", deviceIds)).stream();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,14 @@ public void logVaultAccessGranted(String grantedBy, UUID vaultId, String authori
auditEventRepository.persist(event);
}

public void logVaultKeyRetrieved(String retrievedBy, UUID vaultId, VaultKeyRetrievedEvent.Result result) {
public void logVaultKeyRetrieved(String retrievedBy, UUID vaultId, VaultKeyRetrievedEvent.Result result, String ipAddress, String deviceId) {
var event = new VaultKeyRetrievedEvent();
event.setTimestamp(Instant.now());
event.setRetrievedBy(retrievedBy);
event.setVaultId(vaultId);
event.setResult(result);
event.setIpAddress(ipAddress);
event.setDeviceId(deviceId);
auditEventRepository.persist(event);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ public class VaultKeyRetrievedEvent extends AuditEvent {
@Enumerated(EnumType.STRING)
private Result result;

@Column(name = "ip_address")
private String ipAddress;

@Column(name = "device_id")
private String deviceId;

public String getRetrievedBy() {
return retrievedBy;
}
Expand All @@ -51,25 +57,37 @@ public void setResult(Result result) {
this.result = result;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
VaultKeyRetrievedEvent that = (VaultKeyRetrievedEvent) o;
return super.equals(that) //
&& Objects.equals(retrievedBy, that.retrievedBy) //
&& Objects.equals(vaultId, that.vaultId) //
&& Objects.equals(result, that.result);
public String getIpAddress() {
return ipAddress;
}

@Override
public int hashCode() {
return Objects.hash(super.getId(), retrievedBy, vaultId, result);
public void setIpAddress(String ipAddress) {
this.ipAddress = ipAddress;
}

public String getDeviceId() {
return deviceId;
}

public void setDeviceId(String device) {
this.deviceId = device;
}

public enum Result {
SUCCESS,
UNAUTHORIZED
}

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
VaultKeyRetrievedEvent that = (VaultKeyRetrievedEvent) o;
return Objects.equals(retrievedBy, that.retrievedBy) && Objects.equals(vaultId, that.vaultId) && result == that.result && Objects.equals(ipAddress, that.ipAddress) && Objects.equals(deviceId, that.deviceId);
}

@Override
public int hashCode() {
return Objects.hash(super.hashCode(), retrievedBy, vaultId, result, ipAddress, deviceId);
}
}
2 changes: 2 additions & 0 deletions backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ quarkus.http.header."Cross-Origin-Opener-Policy".value=same-origin
quarkus.http.header."Cross-Origin-Resource-Policy".value=same-origin
quarkus.http.header."Content-Type".value=text/html

%test.quarkus.http.proxy.proxy-address-forwarding=true

# Cache
# /app, /index.html and / for 1min in case hub gets updated
# /api never because the backend content can change at any time
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "audit_event_vault_key_retrieve" ADD "ip_address" VARCHAR(46), ADD "device_id" VARCHAR(255) COLLATE "C";
Loading

0 comments on commit d53abc6

Please sign in to comment.