diff --git a/CHANGELOG.md b/CHANGELOG.md index 312592bda..6efdd90df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,10 @@ 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 @@ -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 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 06d542145..01b88fdc0 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java @@ -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()); @@ -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, diff --git a/backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java b/backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java index 6c994e5da..b917c2581 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java @@ -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; @@ -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; @@ -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); } } diff --git a/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java b/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java index 4f1893b7e..e045cfea2 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java @@ -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; @@ -58,6 +60,8 @@ public class UsersResource { WotEntry.Repository wotRepo; @Inject EffectiveWot.Repository effectiveWotRepo; + @Inject + AuditEvent.Repository auditEventRepo; @Inject JsonWebToken jwt; @@ -156,11 +160,20 @@ public Response updateMyAccessTokens(@NotNull Map 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 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.of(); - return new UserDto(user.getId(), user.getName(), user.getPictureUrl(), user.getEmail(), user.getLanguage(), devices, user.getEcdhPublicKey(), user.getEcdsaPublicKey(), user.getPrivateKeys(), user.getSetupCode()); + Set 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 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 bab3998ff..2fa97b28b 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java @@ -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; @@ -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; @@ -96,6 +98,9 @@ public class VaultResource { @Inject LicenseHolder license; + @Context + HttpServerRequest request; + @GET @Path("/accessible") @RolesAllowed("user") @@ -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."); } } @@ -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."); } } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/events/AuditEvent.java b/backend/src/main/java/org/cryptomator/hub/entities/events/AuditEvent.java index 793592ead..8df4baeb7 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/events/AuditEvent.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/events/AuditEvent.java @@ -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 @@ -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 { @@ -127,5 +139,9 @@ public Stream findAllInPeriod(Instant startDate, Instant endDate, Li query.page(0, pageSize); return query.stream(); } + + public Stream findLastVaultKeyRetrieve(Set deviceIds) { + return find("#AuditEvent.lastVaultKeyRetrieve", Parameters.with("deviceIds", deviceIds)).stream(); + } } } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/events/EventLogger.java b/backend/src/main/java/org/cryptomator/hub/entities/events/EventLogger.java index 779ba2b4d..54880c854 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/events/EventLogger.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/events/EventLogger.java @@ -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); } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/events/VaultKeyRetrievedEvent.java b/backend/src/main/java/org/cryptomator/hub/entities/events/VaultKeyRetrievedEvent.java index ed25d1f3c..81f845a3b 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/events/VaultKeyRetrievedEvent.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/events/VaultKeyRetrievedEvent.java @@ -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; } @@ -51,20 +57,20 @@ 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 { @@ -72,4 +78,16 @@ public enum Result { 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); + } } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index a485fdda6..247af4750 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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 diff --git a/backend/src/main/resources/org/cryptomator/hub/flyway/V19__Store_Ip_Address_of_Vault_Key_Retrieved.sql b/backend/src/main/resources/org/cryptomator/hub/flyway/V19__Store_Ip_Address_of_Vault_Key_Retrieved.sql new file mode 100644 index 000000000..3b0517bd1 --- /dev/null +++ b/backend/src/main/resources/org/cryptomator/hub/flyway/V19__Store_Ip_Address_of_Vault_Key_Retrieved.sql @@ -0,0 +1 @@ +ALTER TABLE "audit_event_vault_key_retrieve" ADD "ip_address" VARCHAR(46), ADD "device_id" VARCHAR(255) COLLATE "C"; diff --git a/backend/src/test/java/org/cryptomator/hub/api/DeviceResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/DeviceResourceIT.java index b257a56ed..65ffd9733 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/DeviceResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/DeviceResourceIT.java @@ -61,7 +61,7 @@ public void testCreateNoDeviceDto() { @Order(1) @DisplayName("PUT /devices/ with DTO returns 400") public void testCreateNoDeviceId() { - var deviceDto = new DeviceResource.DeviceDto("device1", "Computer 1", Device.Type.DESKTOP, "publickey1", "jwe.jwe.jwe.user1.device1", "user1", Instant.parse("2020-02-20T20:20:20Z")); + var deviceDto = new DeviceResource.DeviceDto("device1", "Computer 1", Device.Type.DESKTOP, "publickey1", "jwe.jwe.jwe.user1.device1", "user1", Instant.parse("2020-02-20T20:20:20Z"), null, null); given().contentType(ContentType.JSON).body(deviceDto) .when().put("/devices/{deviceId}", " ") //a whitespace .then().statusCode(400); @@ -142,7 +142,7 @@ public void testCreate999() throws SQLException { """); } - var deviceDto = new DeviceResource.DeviceDto("device999", "Computer 999", Device.Type.DESKTOP, "publickey999", "jwe.jwe.jwe.user1.device999", "user1", Instant.parse("2020-02-20T20:20:20Z")); + var deviceDto = new DeviceResource.DeviceDto("device999", "Computer 999", Device.Type.DESKTOP, "publickey999", "jwe.jwe.jwe.user1.device999", "user1", Instant.parse("2020-02-20T20:20:20Z"), null, null); given().contentType(ContentType.JSON).body(deviceDto) .when().put("/devices/{deviceId}", "device999") @@ -160,7 +160,7 @@ public void testCreate999() throws SQLException { @Order(2) @DisplayName("PUT /devices/deviceX returns 201 (creating new device with same name as device1)") public void testCreateX() { - var deviceDto = new DeviceResource.DeviceDto("deviceX", "Computer 1", Device.Type.DESKTOP, "publickey1", "jwe.jwe.jwe.user1.deviceX", "user1", Instant.parse("2020-02-20T20:20:20Z")); + var deviceDto = new DeviceResource.DeviceDto("deviceX", "Computer 1", Device.Type.DESKTOP, "publickey1", "jwe.jwe.jwe.user1.deviceX", "user1", Instant.parse("2020-02-20T20:20:20Z"), null, null); given().contentType(ContentType.JSON).body(deviceDto) .when().put("/devices/{deviceId}", "deviceX") @@ -171,7 +171,7 @@ public void testCreateX() { @Order(3) @DisplayName("PUT /devices/deviceY returns 409 (creating new device with the key of deviceX conflicts)") public void testCreateYWithKeyOfDeviceX() { - var deviceDto = new DeviceResource.DeviceDto("deviceY", "Computer 2", Device.Type.DESKTOP, "publickey1", "jwe.jwe.jwe.user1.deviceX", "user1", Instant.parse("2020-02-20T20:20:20Z")); + var deviceDto = new DeviceResource.DeviceDto("deviceY", "Computer 2", Device.Type.DESKTOP, "publickey1", "jwe.jwe.jwe.user1.deviceX", "user1", Instant.parse("2020-02-20T20:20:20Z"), null, null); given().contentType(ContentType.JSON).body(deviceDto) .when().put("/devices/{deviceId}", "deviceY") @@ -192,7 +192,7 @@ public void testGet999AfterCreate() { @Order(5) @DisplayName("PUT /devices/device999 returns 201 (updating existing device)") public void testUpdate1() { - var deviceDto = new DeviceResource.DeviceDto("device999", "Computer 999 got a new name", Device.Type.DESKTOP, "publickey999", "jwe.jwe.jwe.user1.device999", "user1", Instant.parse("2020-02-20T20:20:20Z")); + var deviceDto = new DeviceResource.DeviceDto("device999", "Computer 999 got a new name", Device.Type.DESKTOP, "publickey999", "jwe.jwe.jwe.user1.device999", "user1", Instant.parse("2020-02-20T20:20:20Z"), null, null); given().contentType(ContentType.JSON).body(deviceDto) .when().put("/devices/{deviceId}", "device999") @@ -251,7 +251,7 @@ public class AsAnonymous { @Test @DisplayName("PUT /devices/device1 returns 401") public void testCreate1() { - var deviceDto = new DeviceResource.DeviceDto("device1", "Device 1", Device.Type.BROWSER, "publickey1", "jwe.jwe.jwe.user1.device1", "user1", Instant.parse("2020-02-20T20:20:20Z")); + var deviceDto = new DeviceResource.DeviceDto("device1", "Device 1", Device.Type.BROWSER, "publickey1", "jwe.jwe.jwe.user1.device1", "user1", Instant.parse("2020-02-20T20:20:20Z"), null, null); given().contentType(ContentType.JSON).body(deviceDto) .when().put("/devices/{deviceId}", "device1") 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 24f6d86c0..60f5e51b6 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java @@ -20,6 +20,7 @@ import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; @@ -156,6 +157,23 @@ public void testUnlock3() { .body(is("jwe.jwe.jwe.vault1.user1")); } + @Test + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/access-token with remote IP and device ID stores it in audit log") + public void testUnlock4() throws SQLException { + given().header("HUB-DEVICE-ID", "123456789123456789") + .header("X-Forwarded-For", "1.2.3.4") + .when().get("/vaults/{vaultId}/access-token", "7E57C0DE-0000-4000-8000-000100001111") + .then().statusCode(200) + .body(is("jwe.jwe.jwe.vault1.user1")); + + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + var rs = s.executeQuery(""" + SELECT * FROM "audit_event_vault_key_retrieve" WHERE "device_id" = '123456789123456789' AND "ip_address" = '1.2.3.4'; + """); + Assertions.assertTrue(rs.next()); + } + } + @Test @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-00010000AAAA/access-token returns 410 for archived vaults") public void testUnlockArchived1() { diff --git a/frontend/src/common/auditlog.ts b/frontend/src/common/auditlog.ts index d32b78f11..973472eec 100644 --- a/frontend/src/common/auditlog.ts +++ b/frontend/src/common/auditlog.ts @@ -81,6 +81,8 @@ export type AuditEventVaultKeyRetrieveDto = AuditEventDtoBase & { retrievedBy: string; vaultId: string; result: 'SUCCESS' | 'UNAUTHORIZED'; + ipAddress?: string; + deviceId?: string; } export type AuditEventVaultMemberAddDto = AuditEventDtoBase & { diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index a1be6a8f3..403bf2b51 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -55,6 +55,8 @@ export type DeviceDto = { publicKey: string; userPrivateKey: string; creationTime: Date; + lastIpAddress?: string; + lastAccessTime?: Date; }; export type VaultRole = 'MEMBER' | 'OWNER'; @@ -202,8 +204,12 @@ class VaultService { .catch((error) => rethrowAndConvertIfExpected(error, 400, 404, 409)); } - public async accessToken(vaultId: string, evenIfArchived = false): Promise { - return axiosAuth.get(`/vaults/${vaultId}/access-token?evenIfArchived=${evenIfArchived}`, { headers: { 'Content-Type': 'text/plain' } }) + public async accessToken(vaultId: string, deviceId?: string, evenIfArchived = false): Promise { + const headers: Record = { 'Content-Type': 'text/plain' }; + if (deviceId) { + headers['Hub-Device-ID'] = deviceId; + } + return axiosAuth.get(`/vaults/${vaultId}/access-token?evenIfArchived=${evenIfArchived}`, { headers }) .then(response => response.data) .catch((error) => rethrowAndConvertIfExpected(error, 402, 403)); } @@ -244,8 +250,8 @@ class UserService { return axiosAuth.put('/users/me', dto); } - public async me(withDevices: boolean = false): Promise { - return axiosAuth.get(`/users/me?withDevices=${withDevices}`).then(response => AuthorityService.fillInMissingPicture(response.data)); + public async me(withDevices: boolean = false, withLastAccess: boolean = false): Promise { + return axiosAuth.get(`/users/me?withDevices=${withDevices}&withLastAccess=${withLastAccess}`).then(response => AuthorityService.fillInMissingPicture(response.data)); } public async resetMe(): Promise { diff --git a/frontend/src/common/userdata.ts b/frontend/src/common/userdata.ts index df16be9a2..0d06175ce 100644 --- a/frontend/src/common/userdata.ts +++ b/frontend/src/common/userdata.ts @@ -5,6 +5,7 @@ import { JWEParser } from './jwe'; class UserData { #me?: Promise; + #meWithLastAccess?: Promise; #browserKeys?: Promise; /** @@ -17,6 +18,14 @@ class UserData { return this.#me; } + public get meWithLastAccess(): Promise { + if (!this.#meWithLastAccess) { + this.#meWithLastAccess = backend.users.me(true, true); + this.#me = this.#meWithLastAccess; + } + return this.#meWithLastAccess; + } + /** * Gets the device key pair stored for this user in the currently used browser. */ diff --git a/frontend/src/components/AuditLogDetailsVaultKeyRetrieve.vue b/frontend/src/components/AuditLogDetailsVaultKeyRetrieve.vue index 96f92bb66..c1efee887 100644 --- a/frontend/src/components/AuditLogDetailsVaultKeyRetrieve.vue +++ b/frontend/src/components/AuditLogDetailsVaultKeyRetrieve.vue @@ -22,6 +22,23 @@ {{ event.vaultId }} +
+
+ ip address +
+
+ {{ event.ipAddress }} +
+
+
+
+ device +
+
+ {{ resolvedDevice.name }} + {{ event.deviceId }} +
+
result @@ -40,7 +57,7 @@ import { onMounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import auditlog, { AuditEventVaultKeyRetrieveDto } from '../common/auditlog'; -import { AuthorityDto, VaultDto } from '../common/backend'; +import { AuthorityDto, DeviceDto, VaultDto } from '../common/backend'; const { t } = useI18n({ useScope: 'global' }); @@ -50,9 +67,13 @@ const props = defineProps<{ const resolvedRetrievedBy = ref(); const resolvedVault = ref(); +const resolvedDevice = ref(); onMounted(async () => { resolvedRetrievedBy.value = await auditlog.entityCache.getAuthority(props.event.retrievedBy); resolvedVault.value = await auditlog.entityCache.getVault(props.event.vaultId); + if (props.event.deviceId) { + resolvedDevice.value = await auditlog.entityCache.getDevice(props.event.deviceId); + } }); diff --git a/frontend/src/components/DeviceList.vue b/frontend/src/components/DeviceList.vue index 9545bb937..c46763a03 100644 --- a/frontend/src/components/DeviceList.vue +++ b/frontend/src/components/DeviceList.vue @@ -29,6 +29,22 @@ {{ t('deviceList.added') }} + + + {{ t('deviceList.lastAccess') }} +
+ +
+
+ + + + {{ t('deviceList.ipAddress') }} + + + + + {{ t('common.remove') }} @@ -60,6 +76,12 @@ {{ d(device.creationTime, 'short') }} + +
{{ d(device.lastAccessTime, 'short') }}
+ + +
{{ device.lastIpAddress }}
+ {{ t('common.remove') }} @@ -81,7 +103,7 @@