Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ipAddress and deviceId to vault key retrieve event and show last device access in user profile #320

Merged
merged 15 commits into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,22 @@ public List<AuditEventDto> getAllEvents(@QueryParam("startDate") Instant startDa
return auditEventRepo.findAllInPeriod(startDate, endDate, type, paginationId, order.equals("asc"), pageSize).map(AuditEventDto::fromEntity).toList();
}

@GET
@Path("/last-vault-key-retrieve")
@RolesAllowed("admin")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "list last vault key retrieve auditlog entry by device id", description = "list last vault key retrieve auditlog entry by device id")
@Parameter(name = "deviceIds", description = "list of deviceIds", in = ParameterIn.QUERY)
@APIResponse(responseCode = "200", description = "Body contains list of events")
@APIResponse(responseCode = "402", description = "Community license used or license expired")
@APIResponse(responseCode = "403", description = "requesting user does not have admin role")
public List<AuditEventDto> lastVaultKeyRetrieve(@QueryParam("deviceIds") List<String> deviceIds) {
if (!license.isSet() || license.isExpired()) {
throw new PaymentRequiredException("Community license used or license expired");
}
return auditEventRepo.findLastVaultKeyRetrieve(deviceIds).map(AuditEventDto::fromEntity).toList();
}

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
@JsonSubTypes({ //
@JsonSubTypes.Type(value = DeviceRegisteredEventDto.class, name = DeviceRegisteredEvent.TYPE), //
Expand Down Expand Up @@ -130,7 +146,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 +193,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
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().toString();
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().host();
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 @@ -45,6 +45,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 +138,9 @@ public Stream<AuditEvent> findAllInPeriod(Instant startDate, Instant endDate, Li
query.page(0, pageSize);
return query.stream();
}

public Stream<AuditEvent> findLastVaultKeyRetrieve(List<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);
}
}
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";
12 changes: 12 additions & 0 deletions frontend/src/common/auditlog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export type AuditEventVaultKeyRetrieveDto = AuditEventDtoBase & {
retrievedBy: string;
vaultId: string;
result: 'SUCCESS' | 'UNAUTHORIZED';
ipAddress?: string;
deviceId?: string;
}

export type AuditEventVaultMemberAddDto = AuditEventDtoBase & {
Expand Down Expand Up @@ -187,6 +189,16 @@ class AuditLogService {
}))
.catch((error) => rethrowAndConvertIfExpected(error, 402));
}

public async lastVaultKeyRetrieveEvents(deviceIds: string[]): Promise<AuditEventDto[]> {
const query = `deviceIds=${deviceIds.join('&deviceIds=')}`;
return axiosAuth.get<AuditEventDto[]>(`/auditlog/last-vault-key-retrieve?${query}`)
.then(response => response.data.map(dto => {
dto.timestamp = new Date(dto.timestamp);
return dto;
}))
.catch((error) => rethrowAndConvertIfExpected(error, 402));
}
}

/* Export */
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/common/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,12 @@ class VaultService {
.catch((error) => rethrowAndConvertIfExpected(error, 400, 404, 409));
}

public async accessToken(vaultId: string, evenIfArchived = false): Promise<string> {
return axiosAuth.get(`/vaults/${vaultId}/access-token?evenIfArchived=${evenIfArchived}`, { headers: { 'Content-Type': 'text/plain' } })
public async accessToken(vaultId: string, deviceId?: string, evenIfArchived = false): Promise<string> {
const headers: Record<string, string> = { '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));
}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/UserProfile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headless
import { ArrowTopRightOnSquareIcon, CheckIcon, ChevronUpDownIcon, LanguageIcon } from '@heroicons/vue/24/solid';
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import auditlog from '../common/auditlog';
import backend, { UserDto, VersionDto } from '../common/backend';

import config from '../common/config';
Expand Down Expand Up @@ -93,6 +94,9 @@ async function fetchData() {
try {
me.value = await userdata.me;
version.value = await backend.version.get();
const deviceIds = me.value.devices.map(d => d.id);
const lastVaultKeyRetrieveEvents = await auditlog.service.lastVaultKeyRetrieveEvents(deviceIds);
console.log(lastVaultKeyRetrieveEvents);
} catch (error) {
console.error('Retrieving user information failed.', error);
onFetchError.value = error instanceof Error ? error : new Error('Unknown Error');
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/VaultDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,8 @@ async function fetchOwnerData() {
await refreshTrusts();
usersRequiringAccessGrant.value = await backend.vaults.getUsersRequiringAccessGrant(props.vaultId);
vaultRecoveryRequired.value = false;
const vaultKeyJwe = await backend.vaults.accessToken(props.vaultId, true);
const deviceId = await (await userdata.browserKeys)?.id();
const vaultKeyJwe = await backend.vaults.accessToken(props.vaultId, deviceId, true);
vaultKeys.value = await loadVaultKeys(vaultKeyJwe);
} catch (error) {
if (error instanceof ForbiddenError) {
Expand Down