diff --git a/backend/pom.xml b/backend/pom.xml index d80c05bef..0ae5ead5a 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -4,7 +4,7 @@ 4.0.0 org.cryptomator hub-backend - 1.2.2 + 1.3.0 3.11.0 @@ -13,7 +13,7 @@ UTF-8 cryptomator hub - 3.2.6.Final + 3.4.3 eclipse-temurin:17-jre 4.4.0 3.1.2 diff --git a/backend/src/main/java/org/cryptomator/hub/KeycloakRemoteUserProvider.java b/backend/src/main/java/org/cryptomator/hub/KeycloakRemoteUserProvider.java index e5d6be6b0..e56be0bc2 100644 --- a/backend/src/main/java/org/cryptomator/hub/KeycloakRemoteUserProvider.java +++ b/backend/src/main/java/org/cryptomator/hub/KeycloakRemoteUserProvider.java @@ -44,9 +44,24 @@ List users(RealmResource realm) { users.addAll(currentRequestedUsers); } while (currentRequestedUsers.size() == MAX_COUNT_PER_REQUEST); + var cliUser = cryptomatorCliUser(realm); + cliUser.ifPresent(users::add); + return users; } + //visible for testing + Optional cryptomatorCliUser(RealmResource realm) { + var clients = realm.clients().findByClientId("cryptomatorhub-cli"); + if (clients.isEmpty()) { + return Optional.empty(); + } + var clientId = clients.get(0).getId(); + var client = realm.clients().get(clientId); + var clientUser = client.getServiceAccountUser(); + return Optional.of(mapToUser(clientUser)); + } + private Predicate notSyncerUser() { return user -> !user.getUsername().equals(syncerConfig.getUsername()); } diff --git a/backend/src/main/java/org/cryptomator/hub/api/ActionRequiredException.java b/backend/src/main/java/org/cryptomator/hub/api/ActionRequiredException.java new file mode 100644 index 000000000..c33eebeb4 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/api/ActionRequiredException.java @@ -0,0 +1,24 @@ +package org.cryptomator.hub.api; + +import jakarta.ws.rs.ClientErrorException; + +class ActionRequiredException extends ClientErrorException { + public static final int STATUS = 449; + + public ActionRequiredException() { + super(STATUS); + } + + public ActionRequiredException(String message) { + super(message, STATUS); + } + + public ActionRequiredException(Throwable cause) { + super(STATUS, cause); + } + + public ActionRequiredException(String msg, Throwable cause) { + super(msg, STATUS, cause); + } + +} 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 c9e77a8f0..df3621e61 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; -import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.BadRequestException; @@ -21,8 +20,11 @@ import org.cryptomator.hub.entities.AuditEventVaultKeyRetrieve; import org.cryptomator.hub.entities.AuditEventVaultMemberAdd; import org.cryptomator.hub.entities.AuditEventVaultMemberRemove; +import org.cryptomator.hub.entities.AuditEventVaultMemberUpdate; +import org.cryptomator.hub.entities.AuditEventVaultOwnershipClaim; import org.cryptomator.hub.entities.AuditEventVaultUpdate; import org.cryptomator.hub.entities.Device; +import org.cryptomator.hub.entities.VaultAccess; import org.cryptomator.hub.license.LicenseHolder; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.enums.ParameterIn; @@ -34,7 +36,6 @@ import java.util.UUID; @Path("/auditlog") -@RegisterForReflection(targets = {UUID[].class}) public class AuditLogResource { @Inject @@ -82,7 +83,9 @@ public List getAllEvents(@QueryParam("startDate") Instant startDa @JsonSubTypes.Type(value = AuditEventVaultAccessGrantDto.class, name = AuditEventVaultAccessGrant.TYPE), // @JsonSubTypes.Type(value = AuditEventVaultKeyRetrieveDto.class, name = AuditEventVaultKeyRetrieve.TYPE), // @JsonSubTypes.Type(value = AuditEventVaultMemberAddDto.class, name = AuditEventVaultMemberAdd.TYPE), // - @JsonSubTypes.Type(value = AuditEventVaultMemberRemoveDto.class, name = AuditEventVaultMemberRemove.TYPE) // + @JsonSubTypes.Type(value = AuditEventVaultMemberRemoveDto.class, name = AuditEventVaultMemberRemove.TYPE), // + @JsonSubTypes.Type(value = AuditEventVaultMemberUpdateDto.class, name = AuditEventVaultMemberUpdate.TYPE), // + @JsonSubTypes.Type(value = AuditEventVaultOwnershipClaimDto.class, name = AuditEventVaultOwnershipClaim.TYPE) // }) public interface AuditEventDto { @@ -107,9 +110,13 @@ static AuditEventDto fromEntity(AuditEvent entity) { } else if (entity instanceof AuditEventVaultKeyRetrieve evt) { return new AuditEventVaultKeyRetrieveDto(evt.id, evt.timestamp, AuditEventVaultKeyRetrieve.TYPE, evt.retrievedBy, evt.vaultId, evt.result); } else if (entity instanceof AuditEventVaultMemberAdd evt) { - return new AuditEventVaultMemberAddDto(evt.id, evt.timestamp, AuditEventVaultMemberAdd.TYPE, evt.addedBy, evt.vaultId, evt.authorityId); + return new AuditEventVaultMemberAddDto(evt.id, evt.timestamp, AuditEventVaultMemberAdd.TYPE, evt.addedBy, evt.vaultId, evt.authorityId, evt.role); } else if (entity instanceof AuditEventVaultMemberRemove evt) { return new AuditEventVaultMemberRemoveDto(evt.id, evt.timestamp, AuditEventVaultMemberRemove.TYPE, evt.removedBy, evt.vaultId, evt.authorityId); + } else if (entity instanceof AuditEventVaultMemberUpdate evt) { + return new AuditEventVaultMemberUpdateDto(evt.id, evt.timestamp, AuditEventVaultMemberUpdate.TYPE, evt.updatedBy, evt.vaultId, evt.authorityId, evt.role); + } else if (entity instanceof AuditEventVaultOwnershipClaim evt) { + return new AuditEventVaultOwnershipClaimDto(evt.id, evt.timestamp, AuditEventVaultOwnershipClaim.TYPE, evt.claimedBy, evt.vaultId); } else { throw new UnsupportedOperationException("conversion not implemented for event type " + entity.getClass()); } @@ -139,12 +146,19 @@ record AuditEventVaultKeyRetrieveDto(long id, Instant timestamp, String type, @J @JsonProperty("result") AuditEventVaultKeyRetrieve.Result result) implements AuditEventDto { } - record AuditEventVaultMemberAddDto(long id, Instant timestamp, String type, @JsonProperty("addedBy") String addedBy, @JsonProperty("vaultId") UUID vaultId, - @JsonProperty("authorityId") String authorityId) implements AuditEventDto { + record AuditEventVaultMemberAddDto(long id, Instant timestamp, String type, @JsonProperty("addedBy") String addedBy, @JsonProperty("vaultId") UUID vaultId, @JsonProperty("authorityId") String authorityId, + @JsonProperty("role") VaultAccess.Role role) implements AuditEventDto { } record AuditEventVaultMemberRemoveDto(long id, Instant timestamp, String type, @JsonProperty("removedBy") String removedBy, @JsonProperty("vaultId") UUID vaultId, @JsonProperty("authorityId") String authorityId) implements AuditEventDto { } + record AuditEventVaultMemberUpdateDto(long id, Instant timestamp, String type, @JsonProperty("updatedBy") String updatedBy, @JsonProperty("vaultId") UUID vaultId, @JsonProperty("authorityId") String authorityId, + @JsonProperty("role") VaultAccess.Role role) implements AuditEventDto { + } + + record AuditEventVaultOwnershipClaimDto(long id, Instant timestamp, String type, @JsonProperty("claimedBy") String claimedBy, @JsonProperty("vaultId") UUID vaultId) implements AuditEventDto { + } + } diff --git a/backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java b/backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java index 01eff0ed0..dcdc6666b 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java +++ b/backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java @@ -5,7 +5,7 @@ import org.cryptomator.hub.entities.Group; import org.cryptomator.hub.entities.User; -abstract sealed class AuthorityDto permits UserDto, GroupDto { +abstract sealed class AuthorityDto permits UserDto, GroupDto, MemberDto { public enum Type { USER, GROUP @@ -31,12 +31,13 @@ protected AuthorityDto(String id, Type type, String name, String pictureUrl) { } static AuthorityDto fromEntity(Authority a) { - if (a instanceof User u) { - return new UserDto(u.id, u.name, u.pictureUrl, u.email, null); - } else if (a instanceof Group) { - return new GroupDto(a.id, a.name); + // TODO refactor to JEP 441 in JDK 21 + if (a instanceof User user) { + return UserDto.justPublicInfo(user); + } else if (a instanceof Group group) { + return GroupDto.fromEntity(group); } else { - throw new IllegalArgumentException("Authority of this type does not exist"); + throw new IllegalStateException("authority is not of type user or group"); } } diff --git a/backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java b/backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java index 966fb5ca2..774d3aa43 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java @@ -30,10 +30,10 @@ public List search(@QueryParam("query") @NotBlank String query) { @GET @Path("/") - @RolesAllowed("admin") + @RolesAllowed("user") @Produces(MediaType.APPLICATION_JSON) @NoCache - @Operation(summary = "lists all authorities matching the given ids", description ="lists for each id in the list its corresponding authority. Ignores all id's where an authority cannot be found") + @Operation(summary = "lists all authorities matching the given ids", description = "lists for each id in the list its corresponding authority. Ignores all id's where an authority cannot be found") @APIResponse(responseCode = "200") public List getSome(@QueryParam("ids") List authorityIds) { return Authority.findAllInList(authorityIds).map(AuthorityDto::fromEntity).toList(); diff --git a/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java b/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java index 8f00884c2..1fb18c6db 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java @@ -6,6 +6,7 @@ import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.transaction.Transactional; +import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; import jakarta.ws.rs.PUT; @@ -51,10 +52,10 @@ public BillingDto get() { @RolesAllowed("admin") @Consumes(MediaType.TEXT_PLAIN) @Operation(summary = "set the token") - @APIResponse(responseCode = "204") + @APIResponse(responseCode = "204", description = "token set") @APIResponse(responseCode = "400", description = "token is invalid (e.g., expired or invalid signature)") @APIResponse(responseCode = "403", description = "only admins are allowed to set the token") - public Response setToken(@ValidJWS String token) { + public Response setToken(@NotNull @ValidJWS String token) { try { licenseHolder.set(token); return Response.status(Response.Status.NO_CONTENT).build(); @@ -69,7 +70,7 @@ public record BillingDto(@JsonProperty("hubId") String hubId, @JsonProperty("has public static BillingDto create(String hubId, LicenseHolder licenseHolder) { var seats = licenseHolder.getNoLicenseSeats(); - var remainingSeats = Math.max(seats - EffectiveVaultAccess.countEffectiveVaultUsers(), 0); + var remainingSeats = Math.max(seats - EffectiveVaultAccess.countSeatOccupyingUsers(), 0); var managedInstance = licenseHolder.isManagedInstance(); return new BillingDto(hubId, false, null, (int) seats, (int) remainingSeats, null, null, managedInstance); } @@ -78,7 +79,7 @@ public static BillingDto fromDecodedJwt(DecodedJWT jwt, LicenseHolder licenseHol var id = jwt.getId(); var email = jwt.getSubject(); var totalSeats = jwt.getClaim("seats").asInt(); - var remainingSeats = Math.max(totalSeats - (int) EffectiveVaultAccess.countEffectiveVaultUsers(), 0); + var remainingSeats = Math.max(totalSeats - (int) EffectiveVaultAccess.countSeatOccupyingUsers(), 0); var issuedAt = jwt.getIssuedAt().toInstant(); var expiresAt = jwt.getExpiresAt().toInstant(); var managedInstance = licenseHolder.isManagedInstance(); diff --git a/backend/src/main/java/org/cryptomator/hub/api/ConfigResource.java b/backend/src/main/java/org/cryptomator/hub/api/ConfigResource.java index 224220d8e..fef26f726 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/ConfigResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/ConfigResource.java @@ -49,7 +49,7 @@ public ConfigDto getConfig() { var authUri = replacePrefix(oidcConfData.getAuthorizationUri(), trimTrailingSlash(internalRealmUrl), publicRealmUri); var tokenUri = replacePrefix(oidcConfData.getTokenUri(), trimTrailingSlash(internalRealmUrl), publicRealmUri); - return new ConfigDto(keycloakPublicUrl, keycloakRealm, keycloakClientIdHub, keycloakClientIdCryptomator, authUri, tokenUri, Instant.now().truncatedTo(ChronoUnit.MILLIS), 0); + return new ConfigDto(keycloakPublicUrl, keycloakRealm, keycloakClientIdHub, keycloakClientIdCryptomator, authUri, tokenUri, Instant.now().truncatedTo(ChronoUnit.MILLIS), 1); } //visible for testing 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 05bd96310..a043465f6 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java @@ -3,15 +3,16 @@ import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; -import jakarta.persistence.PersistenceException; +import jakarta.persistence.NoResultException; import jakarta.transaction.Transactional; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.ClientErrorException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; -import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; @@ -19,27 +20,33 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import org.cryptomator.hub.entities.Device; import org.cryptomator.hub.entities.AuditEventDeviceRegister; import org.cryptomator.hub.entities.AuditEventDeviceRemove; +import org.cryptomator.hub.entities.AuditEventVaultAccessGrant; +import org.cryptomator.hub.entities.Device; +import org.cryptomator.hub.entities.LegacyDevice; import org.cryptomator.hub.entities.User; import org.cryptomator.hub.validation.NoHtmlOrScriptChars; -import org.cryptomator.hub.validation.OnlyBase64UrlChars; +import org.cryptomator.hub.validation.OnlyBase64Chars; import org.cryptomator.hub.validation.ValidId; +import org.cryptomator.hub.validation.ValidJWE; import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.hibernate.exception.ConstraintViolationException; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.NoCache; import java.net.URI; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; -import java.util.Set; @Path("/devices") public class DeviceResource { + private static final Logger LOG = Logger.getLogger(DeviceResource.class); + @Inject JsonWebToken jwt; @@ -60,25 +67,53 @@ public List getSome(@QueryParam("ids") List deviceIds) { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @Transactional - @Operation(summary = "adds a device", description = "the device will be owned by the currently logged-in user") - @APIResponse(responseCode = "201", description = "device created") - @APIResponse(responseCode = "409", description = "Device already exists") - public Response create(@Valid DeviceDto deviceDto, @PathParam("deviceId") @ValidId String deviceId) { - if (deviceId == null || deviceId.trim().length() == 0 || deviceDto == null) { - return Response.status(Response.Status.BAD_REQUEST).entity("deviceId or deviceDto cannot be empty").build(); + @Operation(summary = "creates or updates a device", description = "the device will be owned by the currently logged-in user") + @APIResponse(responseCode = "201", description = "Device created or updated") + @APIResponse(responseCode = "409", description = "Device with this key already exists") + public Response createOrUpdate(@Valid @NotNull DeviceDto dto, @PathParam("deviceId") @ValidId String deviceId) { + Device device; + try { + device = Device.findByIdAndUser(deviceId, jwt.getSubject()); + } catch (NoResultException e) { + device = new Device(); + device.id = deviceId; + device.owner = User.findById(jwt.getSubject()); + device.creationTime = Instant.now().truncatedTo(ChronoUnit.MILLIS); + device.type = dto.type != null ? dto.type : Device.Type.DESKTOP; // default to desktop for backwards compatibility + + if (LegacyDevice.deleteById(device.id)) { + assert LegacyDevice.findById(device.id) == null; + LOG.info("Deleted Legacy Device during re-registration of Device " + deviceId); + } } - User currentUser = User.findById(jwt.getSubject()); - var device = deviceDto.toDevice(currentUser, deviceId, Instant.now().truncatedTo(ChronoUnit.MILLIS)); + device.name = dto.name; + device.publickey = dto.publicKey; + device.userPrivateKey = dto.userPrivateKey; + try { device.persistAndFlush(); AuditEventDeviceRegister.log(jwt.getSubject(), deviceId, device.name, device.type); return Response.created(URI.create(".")).build(); - } catch (PersistenceException e) { - if (e instanceof ConstraintViolationException) { - throw new ClientErrorException(Response.Status.CONFLICT, e); - } else { - throw new InternalServerErrorException(e); - } + } catch (ConstraintViolationException e) { + throw new ClientErrorException(Response.Status.CONFLICT, e); + } + } + + @GET + @Path("/{deviceId}") + @RolesAllowed("user") + @Produces(MediaType.APPLICATION_JSON) + @NoCache + @Transactional + @Operation(summary = "get the device", description = "the device must be owned by the currently logged-in user") + @APIResponse(responseCode = "200", description = "Device found") + @APIResponse(responseCode = "404", description = "Device not found or owned by a different user") + public DeviceDto get(@PathParam("deviceId") @ValidId String deviceId) { + try { + Device device = Device.findByIdAndUser(deviceId, jwt.getSubject()); + return DeviceDto.fromEntity(device); + } catch (NoResultException e) { + throw new NotFoundException(e); } } @@ -109,24 +144,13 @@ public Response remove(@PathParam("deviceId") @ValidId String deviceId) { public record DeviceDto(@JsonProperty("id") @ValidId String id, @JsonProperty("name") @NoHtmlOrScriptChars @NotBlank String name, @JsonProperty("type") Device.Type type, - @JsonProperty("publicKey") @OnlyBase64UrlChars String publicKey, + @JsonProperty("publicKey") @NotNull @OnlyBase64Chars String publicKey, + @JsonProperty("userPrivateKey") @NotNull @ValidJWE String userPrivateKey, @JsonProperty("owner") @ValidId String ownerId, - @JsonProperty("accessTo") @Valid Set accessTo, @JsonProperty("creationTime") Instant creationTime) { - public Device toDevice(User user, String id, Instant creationTime) { - var device = new Device(); - device.id = id; - device.owner = user; - device.name = name; - device.type = type != null ? type : Device.Type.DESKTOP; // default to desktop for backwards compatibility - device.publickey = publicKey; - device.creationTime = creationTime; - return device; - } - public static DeviceDto fromEntity(Device entity) { - return new DeviceDto(entity.id, entity.name, entity.type, entity.publickey, entity.owner.id, Set.of(), entity.creationTime.truncatedTo(ChronoUnit.MILLIS)); + return new DeviceDto(entity.id, entity.name, entity.type, entity.publickey, entity.userPrivateKey, entity.owner.id, entity.creationTime.truncatedTo(ChronoUnit.MILLIS)); } } diff --git a/backend/src/main/java/org/cryptomator/hub/api/GroupsResource.java b/backend/src/main/java/org/cryptomator/hub/api/GroupsResource.java new file mode 100644 index 000000000..32272995a --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/api/GroupsResource.java @@ -0,0 +1,37 @@ +package org.cryptomator.hub.api; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.cryptomator.hub.entities.EffectiveGroupMembership; +import org.cryptomator.hub.entities.Group; +import org.cryptomator.hub.validation.ValidId; +import org.eclipse.microprofile.openapi.annotations.Operation; + +import java.util.List; + +@Path("/groups") +public class GroupsResource { + + @GET + @Path("/") + @RolesAllowed("user") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "list all groups") + public List getAll() { + return Group.findAll().stream().map(GroupDto::fromEntity).toList(); + } + + @GET + @Path("/{groupId}/effective-members") + @RolesAllowed("user") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "list all effective group members") + public List getEffectiveMembers(@PathParam("groupId") @ValidId String groupId) { + return EffectiveGroupMembership.getEffectiveGroupUsers(groupId).map(UserDto::justPublicInfo).toList(); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/org/cryptomator/hub/api/MemberDto.java b/backend/src/main/java/org/cryptomator/hub/api/MemberDto.java new file mode 100644 index 000000000..e8c152825 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/api/MemberDto.java @@ -0,0 +1,26 @@ +package org.cryptomator.hub.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.cryptomator.hub.entities.Group; +import org.cryptomator.hub.entities.User; +import org.cryptomator.hub.entities.VaultAccess; + +public final class MemberDto extends AuthorityDto { + + @JsonProperty("role") + public final VaultAccess.Role role; + + MemberDto(@JsonProperty("id") String id, @JsonProperty("type") AuthorityDto.Type type, @JsonProperty("name") String name, @JsonProperty("pictureUrl") String pictureUrl, @JsonProperty("role") VaultAccess.Role role) { + super(id, type, name, pictureUrl); + this.role = role; + } + + public static MemberDto fromEntity(User user, VaultAccess.Role role) { + return new MemberDto(user.id, Type.USER, user.name, user.pictureUrl, role); + } + + public static MemberDto fromEntity(Group group, VaultAccess.Role role) { + return new MemberDto(group.id, Type.GROUP, group.name, null, role); + } + +} diff --git a/backend/src/main/java/org/cryptomator/hub/api/PaymentRequiredException.java b/backend/src/main/java/org/cryptomator/hub/api/PaymentRequiredException.java index ccbc3d0cf..37137e619 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/PaymentRequiredException.java +++ b/backend/src/main/java/org/cryptomator/hub/api/PaymentRequiredException.java @@ -3,7 +3,7 @@ import jakarta.ws.rs.ClientErrorException; import jakarta.ws.rs.core.Response; -public class PaymentRequiredException extends ClientErrorException { +class PaymentRequiredException extends ClientErrorException { public PaymentRequiredException() { super(Response.Status.PAYMENT_REQUIRED); } diff --git a/backend/src/main/java/org/cryptomator/hub/api/UserDto.java b/backend/src/main/java/org/cryptomator/hub/api/UserDto.java index 70c2c9bcf..4e4f3b02b 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/UserDto.java +++ b/backend/src/main/java/org/cryptomator/hub/api/UserDto.java @@ -1,7 +1,10 @@ package org.cryptomator.hub.api; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.annotation.Nullable; import org.cryptomator.hub.entities.User; +import org.cryptomator.hub.validation.OnlyBase64Chars; +import org.cryptomator.hub.validation.ValidJWE; import java.util.Set; @@ -11,14 +14,24 @@ public final class UserDto extends AuthorityDto { public final String email; @JsonProperty("devices") public final Set devices; + @JsonProperty("publicKey") + public final String publicKey; + @JsonProperty("privateKey") + public final String privateKey; + @JsonProperty("setupCode") + public final String setupCode; - UserDto(@JsonProperty("id") String id, @JsonProperty("name") String name, @JsonProperty("pictureUrl") String pictureUrl, @JsonProperty("email") String email, @JsonProperty("devices") Set devices) { + UserDto(@JsonProperty("id") String id, @JsonProperty("name") String name, @JsonProperty("pictureUrl") String pictureUrl, @JsonProperty("email") String email, @JsonProperty("devices") Set devices, + @Nullable @JsonProperty("publicKey") @OnlyBase64Chars String publicKey, @Nullable @JsonProperty("privateKey") @ValidJWE String privateKey, @Nullable @JsonProperty("setupCode") @ValidJWE String setupCode) { super(id, Type.USER, name, pictureUrl); this.email = email; this.devices = devices; + this.publicKey = publicKey; + this.privateKey = privateKey; + this.setupCode = setupCode; } - public static UserDto fromEntity(User user) { - return new UserDto(user.id, user.name, user.pictureUrl, user.email, Set.of()); + public static UserDto justPublicInfo(User user) { + return new UserDto(user.id, user.name, user.pictureUrl, user.email, Set.of(), user.publicKey, null, null); } } 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 88a4bc91c..96efb20da 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java @@ -1,9 +1,13 @@ package org.cryptomator.hub.api; +import jakarta.annotation.Nullable; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; @@ -35,10 +39,11 @@ public class UsersResource { @PUT @Path("/me") @RolesAllowed("user") + @Consumes(MediaType.APPLICATION_JSON) @Transactional - @Operation(summary = "sync the logged-in user from the remote user provider to hub") + @Operation(summary = "update the logged-in user") @APIResponse(responseCode = "201", description = "user created or updated") - public Response syncMe() { + public Response putMe(@Nullable @Valid UserDto dto) { var userId = jwt.getSubject(); User user = User.findById(userId); if (user == null) { @@ -48,6 +53,11 @@ public Response syncMe() { user.name = jwt.getName(); user.pictureUrl = jwt.getClaim("picture"); user.email = jwt.getClaim("email"); + if (dto != null) { + user.publicKey = dto.publicKey; + user.privateKey = dto.privateKey; + user.setupCode = dto.setupCode; + } user.persist(); return Response.created(URI.create(".")).build(); } @@ -59,16 +69,31 @@ public Response syncMe() { @NoCache @Transactional @Operation(summary = "get the logged-in user") - public UserDto getMe(@QueryParam("withDevices") boolean withDevices, @QueryParam("withAccessibleVaults") boolean withAccessibleVaults) { + @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) { + User user = User.findById(jwt.getSubject()); + Function mapDevices = d -> new DeviceResource.DeviceDto(d.id, d.name, d.type, d.publickey, d.userPrivateKey, d.owner.id, d.creationTime.truncatedTo(ChronoUnit.MILLIS)); + var devices = withDevices ? user.devices.stream().map(mapDevices).collect(Collectors.toSet()) : Set.of(); + return new UserDto(user.id, user.name, user.pictureUrl, user.email, devices, user.publicKey, user.privateKey, user.setupCode); + } + + @POST + @Path("/me/reset") + @RolesAllowed("user") + @NoCache + @Transactional + @Operation(summary = "resets the user account") + @APIResponse(responseCode = "204", description = "deleted keys, devices and access permissions") + public Response resetMe() { User user = User.findById(jwt.getSubject()); - Function mapAccessibleVaults = - a -> new VaultResource.VaultDto(a.vault.id, a.vault.name, a.vault.description, a.vault.archived, a.vault.creationTime.truncatedTo(ChronoUnit.MILLIS), null, 0, null, null, null); - Function mapDevices = withAccessibleVaults // - ? d -> new DeviceResource.DeviceDto(d.id, d.name, d.type, d.publickey, d.owner.id, d.accessTokens.stream().map(mapAccessibleVaults).collect(Collectors.toSet()), d.creationTime.truncatedTo(ChronoUnit.MILLIS)) // - : d -> new DeviceResource.DeviceDto(d.id, d.name, d.type, d.publickey, d.owner.id, Set.of(), d.creationTime.truncatedTo(ChronoUnit.MILLIS)); - return withDevices // - ? new UserDto(user.id, user.name, user.pictureUrl, user.email, user.devices.stream().map(mapDevices).collect(Collectors.toSet())) - : new UserDto(user.id, user.name, user.pictureUrl, user.email, Set.of()); + user.publicKey = null; + user.privateKey = null; + user.setupCode = null; + user.persist(); + Device.deleteByOwner(user.id); + AccessToken.deleteByUser(user.id); + return Response.noContent().build(); } @GET @@ -77,7 +102,7 @@ public UserDto getMe(@QueryParam("withDevices") boolean withDevices, @QueryParam @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "list all users") public List getAll() { - return User.findAll().stream().map(UserDto::fromEntity).toList(); + return User.findAll().stream().map(UserDto::justPublicInfo).toList(); } } \ No newline at end of file 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 37135b13d..6dc323f3f 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java @@ -1,22 +1,28 @@ package org.cryptomator.hub.api; +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; import com.fasterxml.jackson.annotation.JsonProperty; -import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.security.identity.SecurityIdentity; +import jakarta.annotation.Nullable; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; -import jakarta.persistence.PersistenceException; import jakarta.transaction.Transactional; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.ClientErrorException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; -import jakarta.ws.rs.InternalServerErrorException; import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; @@ -30,22 +36,28 @@ import org.cryptomator.hub.entities.AuditEventVaultKeyRetrieve; import org.cryptomator.hub.entities.AuditEventVaultMemberAdd; import org.cryptomator.hub.entities.AuditEventVaultMemberRemove; +import org.cryptomator.hub.entities.AuditEventVaultMemberUpdate; +import org.cryptomator.hub.entities.AuditEventVaultOwnershipClaim; import org.cryptomator.hub.entities.AuditEventVaultUpdate; -import org.cryptomator.hub.entities.Device; +import org.cryptomator.hub.entities.Authority; import org.cryptomator.hub.entities.EffectiveGroupMembership; import org.cryptomator.hub.entities.EffectiveVaultAccess; import org.cryptomator.hub.entities.Group; +import org.cryptomator.hub.entities.LegacyAccessToken; import org.cryptomator.hub.entities.User; import org.cryptomator.hub.entities.Vault; +import org.cryptomator.hub.entities.VaultAccess; import org.cryptomator.hub.filters.ActiveLicense; -import org.cryptomator.hub.filters.VaultAdminOnlyFilter; +import org.cryptomator.hub.filters.VaultRole; import org.cryptomator.hub.license.LicenseHolder; import org.cryptomator.hub.validation.NoHtmlOrScriptChars; import org.cryptomator.hub.validation.OnlyBase64Chars; import org.cryptomator.hub.validation.ValidId; -import org.cryptomator.hub.validation.ValidJWE; +import org.cryptomator.hub.validation.ValidJWS; import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.enums.ParameterIn; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.hibernate.exception.ConstraintViolationException; @@ -53,11 +65,12 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; -import java.util.NoSuchElementException; +import java.util.Map; +import java.util.Optional; import java.util.UUID; +import java.util.stream.Stream; @Path("/vaults") -@RegisterForReflection(targets = {UUID[].class}) public class VaultResource { @Inject @@ -70,14 +83,20 @@ public class VaultResource { LicenseHolder license; @GET - @Path("/") + @Path("/accessible") @RolesAllowed("user") @Produces(MediaType.APPLICATION_JSON) @Transactional - @Operation(summary = "list all accessible vaults", description = "list all vaults that are not archived and have been shared with the currently logged in user or a group this user is part of") - public List getAccessible() { + @Operation(summary = "list all accessible vaults", description = "list all vaults that have been shared with the currently logged in user or a group in wich this user is") + public List getAccessible(@Nullable @QueryParam("role") VaultAccess.Role role) { var currentUserId = jwt.getSubject(); - var resultStream = Vault.findAccessibleByUser(currentUserId); + // TODO refactor to JEP 441 in JDK 21 + final Stream resultStream; + if (role == null) { + resultStream = Vault.findAccessibleByUser(currentUserId); + } else { + resultStream = Vault.findAccessibleByUser(currentUserId, role); + } return resultStream.map(VaultDto::fromEntity).toList(); } @@ -97,7 +116,7 @@ public List getSomeVaults(@QueryParam("ids") List vaultIds) { @RolesAllowed("admin") @Produces(MediaType.APPLICATION_JSON) @Transactional - @Operation(summary = "list all accessible vaults", description = "list all vaults in the system") + @Operation(summary = "list all vaults", description = "list all vaults in the system") public List getAllVaults() { return Vault.findAll().stream().map(VaultDto::fromEntity).toList(); } @@ -105,22 +124,19 @@ public List getAllVaults() { @GET @Path("/{vaultId}/members") @RolesAllowed("user") - @VaultAdminOnlyFilter + @VaultRole(VaultAccess.Role.OWNER) // may throw 403 @Transactional @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "list vault members", description = "list all users that this vault has been shared with") + @Operation(summary = "list vault members", description = "list all users or groups that this vault has been shared with directly (not inherited via group membership)") @APIResponse(responseCode = "200") - @APIResponse(responseCode = "401", description = "VaultAdminAuthorizationJWT not provided") - @APIResponse(responseCode = "403", description = "VaultAdminAuthorizationJWT expired or not yet valid") - @APIResponse(responseCode = "404", description = "vault not found") - public List getMembers(@PathParam("vaultId") UUID vaultId) { - var vault = Vault.findByIdOptional(vaultId).orElseThrow(NotFoundException::new); - - return vault.directMembers.stream().map(authority -> { - if (authority instanceof User u) { - return UserDto.fromEntity(u); - } else if (authority instanceof Group g) { - return GroupDto.fromEntity(g); + @APIResponse(responseCode = "403", description = "not a vault owner") + public List getDirectMembers(@PathParam("vaultId") UUID vaultId) { + return VaultAccess.forVault(vaultId).map(access -> { + // TODO switch to switch expressions, once we can make Authority sealed + if (access.authority instanceof User u) { + return MemberDto.fromEntity(u, access.role); + } else if (access.authority instanceof Group g) { + return MemberDto.fromEntity(g, access.role); } else { throw new IllegalStateException(); } @@ -130,199 +146,221 @@ public List getMembers(@PathParam("vaultId") UUID vaultId) { @PUT @Path("/{vaultId}/users/{userId}") @RolesAllowed("user") - @VaultAdminOnlyFilter + @VaultRole(VaultAccess.Role.OWNER) // may throw 403 @Transactional @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "adds a member to this vault") - @APIResponse(responseCode = "201", description = "member added") - @APIResponse(responseCode = "401", description = "VaultAdminAuthorizationJWT not provided") + @Operation(summary = "adds a user to this vault or updates her role") + @Parameter(name = "role", in = ParameterIn.QUERY, description = "the role to grant to this user (defaults to MEMBER)") + @APIResponse(responseCode = "200", description = "user's role updated") + @APIResponse(responseCode = "201", description = "user added") @APIResponse(responseCode = "402", description = "all seats in license used") - @APIResponse(responseCode = "403", description = "VaultAdminAuthorizationJWT expired or not yet valid") - @APIResponse(responseCode = "404", description = "vault or user not found") - @APIResponse(responseCode = "409", description = "user is already a direct member of the vault") + @APIResponse(responseCode = "403", description = "not a vault owner") + @APIResponse(responseCode = "404", description = "user not found") @ActiveLicense - public Response addUser(@PathParam("vaultId") UUID vaultId, @PathParam("userId") @ValidId String userId) { - var vault = Vault.findByIdOptional(vaultId).orElseThrow(NotFoundException::new); + public Response addUser(@PathParam("vaultId") UUID vaultId, @PathParam("userId") @ValidId String userId, @QueryParam("role") @DefaultValue("MEMBER") VaultAccess.Role role) { + var vault = Vault.findById(vaultId); // // should always be found, since @VaultRole filter would have triggered var user = User.findByIdOptional(userId).orElseThrow(NotFoundException::new); if (!EffectiveVaultAccess.isUserOccupyingSeat(userId)) { //for new user, we need to check if a license seat is available - var usedSeats = EffectiveVaultAccess.countEffectiveVaultUsers(); + var usedSeats = EffectiveVaultAccess.countSeatOccupyingUsers(); if (usedSeats >= license.getAvailableSeats()) { throw new PaymentRequiredException("Number of effective vault users greater than or equal to the available license seats"); } } - if (vault.directMembers.contains(user)) { - return Response.status(Response.Status.CONFLICT).build(); - } - vault.directMembers.add(user); - vault.persist(); - AuditEventVaultMemberAdd.log(jwt.getSubject(), vaultId, userId); - return Response.status(Response.Status.CREATED).build(); + return addAuthority(vault, user, role); } @PUT @Path("/{vaultId}/groups/{groupId}") @RolesAllowed("user") - @VaultAdminOnlyFilter + @VaultRole(VaultAccess.Role.OWNER) // may throw 403 @Transactional @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "adds a group to this vault") - @APIResponse(responseCode = "201", description = "member added") - @APIResponse(responseCode = "401", description = "VaultAdminAuthorizationJWT not provided") + @Operation(summary = "adds a group to this vault or updates its role") + @Parameter(name = "role", in = ParameterIn.QUERY, description = "the role to grant to this group (defaults to MEMBER)") + @APIResponse(responseCode = "200", description = "group's role updated") + @APIResponse(responseCode = "201", description = "group added") @APIResponse(responseCode = "402", description = "used seats + (number of users in group not occupying a seats) exceeds number of total avaible seats in license") - @APIResponse(responseCode = "403", description = "VaultAdminAuthorizationJWT expired or not yet valid") - @APIResponse(responseCode = "404", description = "vault or group not found") - @APIResponse(responseCode = "409", description = "group is already a direct member of the vault") + @APIResponse(responseCode = "403", description = "not a vault owner") + @APIResponse(responseCode = "404", description = "group not found") @ActiveLicense - public Response addGroup(@PathParam("vaultId") UUID vaultId, @PathParam("groupId") @ValidId String groupId) { + public Response addGroup(@PathParam("vaultId") UUID vaultId, @PathParam("groupId") @ValidId String groupId, @QueryParam("role") @DefaultValue("MEMBER") VaultAccess.Role role) { + var vault = Vault.findById(vaultId); // should always be found, since @VaultRole filter would have triggered + var group = Group.findByIdOptional(groupId).orElseThrow(NotFoundException::new); + //usersInGroup - usersInGroupAndPartOfAtLeastOneVault + usersOfAtLeastOneVault - if (EffectiveGroupMembership.countEffectiveGroupUsers(groupId) - EffectiveVaultAccess.countEffectiveVaultUsersOfGroup(groupId) + EffectiveVaultAccess.countEffectiveVaultUsers() > license.getAvailableSeats()) { + if (EffectiveGroupMembership.countEffectiveGroupUsers(groupId) - EffectiveVaultAccess.countSeatOccupyingUsersOfGroup(groupId) + EffectiveVaultAccess.countSeatOccupyingUsers() > license.getAvailableSeats()) { throw new PaymentRequiredException("Number of effective vault users greater than or equal to the available license seats"); } - var vault = Vault.findByIdOptional(vaultId).orElseThrow(NotFoundException::new); - var group = Group.findByIdOptional(groupId).orElseThrow(NotFoundException::new); - if (vault.directMembers.contains(group)) { - return Response.status(Response.Status.CONFLICT).build(); - } - vault.directMembers.add(group); - vault.persist(); - AuditEventVaultMemberAdd.log(jwt.getSubject(), vaultId, groupId); - return Response.status(Response.Status.CREATED).build(); + return addAuthority(vault, group, role); } - @DELETE - @Path("/{vaultId}/users/{userId}") - @RolesAllowed("user") - @VaultAdminOnlyFilter - @Transactional - @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "remove a member from this vault", description = "revokes the given user's access rights from this vault. If the given user is no member, the request is a no-op.") - @APIResponse(responseCode = "204", description = "member removed") - @APIResponse(responseCode = "401", description = "VaultAdminAuthorizationJWT not provided") - @APIResponse(responseCode = "403", description = "VaultAdminAuthorizationJWT expired or not yet valid") - @APIResponse(responseCode = "404", description = "vault not found") - public Response removeMember(@PathParam("vaultId") UUID vaultId, @PathParam("userId") @ValidId String userId) { - return removeAuthority(vaultId, userId); + private Response addAuthority(Vault vault, Authority authority, VaultAccess.Role role) { + var id = new VaultAccess.Id(vault.id, authority.id); + var existingAccess = VaultAccess.findByIdOptional(id); + if (existingAccess.isPresent()) { + var access = existingAccess.get(); + access.role = role; + access.persist(); + AuditEventVaultMemberUpdate.log(jwt.getSubject(), vault.id, authority.id, role); + return Response.ok().build(); + } else { + var access = new VaultAccess(); + access.vault = vault; + access.authority = authority; + access.role = role; + access.persist(); + AuditEventVaultMemberAdd.log(jwt.getSubject(), vault.id, authority.id, role); + return Response.created(URI.create(".")).build(); + } } @DELETE - @Path("/{vaultId}/groups/{groupId}") + @Path("/{vaultId}/authority/{authorityId}") @RolesAllowed("user") - @VaultAdminOnlyFilter + @VaultRole(VaultAccess.Role.OWNER) // may throw 403 @Transactional @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "remove a group from this vault", description = "revokes the given group's access rights from this vault. If the given group is no member, the request is a no-op.") - @APIResponse(responseCode = "204", description = "member removed") - @APIResponse(responseCode = "401", description = "VaultAdminAuthorizationJWT not provided") - @APIResponse(responseCode = "403", description = "VaultAdminAuthorizationJWT expired or not yet valid") - @APIResponse(responseCode = "404", description = "vault not found") - public Response removeGroup(@PathParam("vaultId") UUID vaultId, @PathParam("groupId") @ValidId String groupId) { - return removeAuthority(vaultId, groupId); - } - - private Response removeAuthority(UUID vaultId, String authorityId) { - var vault = Vault.findByIdOptional(vaultId).orElseThrow(NotFoundException::new); - vault.directMembers.removeIf(e -> e.id.equals(authorityId)); - vault.persist(); - AuditEventVaultMemberRemove.log(jwt.getSubject(), vaultId, authorityId); - return Response.status(Response.Status.NO_CONTENT).build(); + @Operation(summary = "remove a user or group from this vault", description = "revokes the given authority's access rights from this vault. If the given authority is no member, the request is a no-op.") + @APIResponse(responseCode = "204", description = "authority removed") + @APIResponse(responseCode = "403", description = "not a vault owner") + public Response removeAuthority(@PathParam("vaultId") UUID vaultId, @PathParam("authorityId") @ValidId String authorityId) { + if (VaultAccess.deleteById(new VaultAccess.Id(vaultId, authorityId))) { + AuditEventVaultMemberRemove.log(jwt.getSubject(), vaultId, authorityId); + return Response.status(Response.Status.NO_CONTENT).build(); + } else { + throw new NotFoundException(); + } } @GET - @Path("/{vaultId}/devices-requiring-access-grant") + @Path("/{vaultId}/users-requiring-access-grant") @RolesAllowed("user") - @VaultAdminOnlyFilter + @VaultRole(VaultAccess.Role.OWNER) // may throw 403 @Transactional @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "list devices requiring access rights", description = "lists all devices owned by vault members, that don't have a device-specific masterkey yet") @APIResponse(responseCode = "200") - @APIResponse(responseCode = "401", description = "VaultAdminAuthorizationJWT not provided") - @APIResponse(responseCode = "403", description = "VaultAdminAuthorizationJWT expired or not yet valid") - @APIResponse(responseCode = "404", description = "vault not found") - public List getDevicesRequiringAccessGrant(@PathParam("vaultId") UUID vaultId) { - return Device.findRequiringAccessGrant(vaultId).map(DeviceResource.DeviceDto::fromEntity).toList(); + @APIResponse(responseCode = "403", description = "not a vault owner") + public List getUsersRequiringAccessGrant(@PathParam("vaultId") UUID vaultId) { + return User.findRequiringAccessGrant(vaultId).map(UserDto::justPublicInfo).toList(); } + @Deprecated(forRemoval = true) @GET @Path("/{vaultId}/keys/{deviceId}") @RolesAllowed("user") @Transactional @Produces(MediaType.TEXT_PLAIN) - @Operation(summary = "get the device-specific masterkey of a non-archived vault") + @Operation(summary = "get the device-specific masterkey of a non-archived vault", deprecated = true) @APIResponse(responseCode = "200") @APIResponse(responseCode = "402", description = "number of effective vault users exceeds available license seats") - @APIResponse(responseCode = "403", description = "device not authorized to access this vault") - @APIResponse(responseCode = "404", description = "vault or device not found") + @APIResponse(responseCode = "403", description = "not authorized to access this vault") @APIResponse(responseCode = "410", description = "Vault is archived") @ActiveLicense - public Response unlock(@PathParam("vaultId") UUID vaultId, @PathParam("deviceId") @ValidId String deviceId) { + public Response legacyUnlock(@PathParam("vaultId") UUID vaultId, @PathParam("deviceId") @ValidId String deviceId) { var vault = Vault.findByIdOptional(vaultId).orElseThrow(NotFoundException::new); if (vault.archived) { throw new GoneException("Vault is archived."); } - var usedSeats = EffectiveVaultAccess.countEffectiveVaultUsers(); + var usedSeats = EffectiveVaultAccess.countSeatOccupyingUsers(); if (usedSeats > license.getAvailableSeats()) { throw new PaymentRequiredException("Number of effective vault users exceeds available license seats"); } - var access = AccessToken.unlock(vaultId, deviceId, jwt.getSubject()); + var access = LegacyAccessToken.unlock(vaultId, deviceId, jwt.getSubject()); if (access != null) { AuditEventVaultKeyRetrieve.log(jwt.getSubject(), vaultId, AuditEventVaultKeyRetrieve.Result.SUCCESS); 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.jwe).header(subscriptionStateHeaderName, subscriptionStateHeaderValue).build(); - } else if (Device.findById(deviceId) == null) { - throw new NotFoundException("No such device."); } else { AuditEventVaultKeyRetrieve.log(jwt.getSubject(), vaultId, AuditEventVaultKeyRetrieve.Result.UNAUTHORIZED); throw new ForbiddenException("Access to this device not granted."); } } - @PUT - @Path("/{vaultId}/keys/{deviceId}") + @GET + @Path("/{vaultId}/access-token") @RolesAllowed("user") - @VaultAdminOnlyFilter + @VaultRole({VaultAccess.Role.MEMBER, VaultAccess.Role.OWNER}) // may throw 403 @Transactional - @Consumes(MediaType.TEXT_PLAIN) - @Operation(summary = "adds a device-specific masterkey to a non-archived vault") - @APIResponse(responseCode = "201", description = "device-specific key stored") - @APIResponse(responseCode = "401", description = "VaultAdminAuthorizationJWT not provided") - @APIResponse(responseCode = "403", description = "VaultAdminAuthorizationJWT expired or not yet valid") - @APIResponse(responseCode = "404", description = "vault or device not found") - @APIResponse(responseCode = "409", description = "Access to vault for device already granted") - @APIResponse(responseCode = "410", description = "Vault is archived") - public Response grantAccess(@PathParam("vaultId") UUID vaultId, @PathParam("deviceId") @ValidId String deviceId, @ValidJWE String jwe) { - var vault = Vault.findByIdOptional(vaultId).orElseThrow(NotFoundException::new); - if (vault.archived) { + @Produces(MediaType.TEXT_PLAIN) + @Operation(summary = "get the user-specific vault key", description = "retrieves a jwe containing the vault key, encrypted for the current user") + @APIResponse(responseCode = "200") + @APIResponse(responseCode = "402", description = "number of effective vault users exceeds available license seats") + @APIResponse(responseCode = "403", description = "not a vault member") + @APIResponse(responseCode = "404", description = "unknown vault") + @APIResponse(responseCode = "410", description = "Vault is archived. Only returned if evenIfArchived query param is false or not set, otherwise the archived flag is ignored") + @APIResponse(responseCode = "449", description = "User account not yet initialized. Retry after setting up user") + @ActiveLicense // may throw 402 + public String unlock(@PathParam("vaultId") UUID vaultId, @QueryParam("evenIfArchived") @DefaultValue("false") boolean ignoreArchived) { + var vault = Vault.findById(vaultId); // should always be found, since @VaultRole filter would have triggered + if (vault.archived && !ignoreArchived) { throw new GoneException("Vault is archived."); } - var device = Device.findByIdOptional(deviceId).orElseThrow(NotFoundException::new); - var access = new AccessToken(); - access.vault = vault; - access.device = device; - access.jwe = jwe; + var usedSeats = EffectiveVaultAccess.countSeatOccupyingUsers(); + if (usedSeats > license.getAvailableSeats()) { + throw new PaymentRequiredException("Number of effective vault users exceeds available license seats"); + } - try { - access.persistAndFlush(); - AuditEventVaultAccessGrant.log(jwt.getSubject(), vaultId, device.owner.id); - return Response.created(URI.create(".")).build(); - } catch (PersistenceException e) { - if (e instanceof ConstraintViolationException) { - throw new ClientErrorException(Response.Status.CONFLICT, e); - } else { - throw new InternalServerErrorException(e); + var user = User.findById(jwt.getSubject()); + if (user.publicKey == null) { + throw new ActionRequiredException("User account not initialized."); + } + + var access = AccessToken.unlock(vaultId, jwt.getSubject()); + if (access != null) { + AuditEventVaultKeyRetrieve.log(jwt.getSubject(), vaultId, AuditEventVaultKeyRetrieve.Result.SUCCESS); + return access.vaultKey; + } else if (Vault.findById(vaultId) == null) { + throw new NotFoundException("No such vault."); + } else { + AuditEventVaultKeyRetrieve.log(jwt.getSubject(), vaultId, AuditEventVaultKeyRetrieve.Result.UNAUTHORIZED); + throw new ForbiddenException("Access to this vault not granted."); + } + } + + @POST + @Path("/{vaultId}/access-tokens") + @RolesAllowed("user") + @VaultRole(VaultAccess.Role.OWNER) // may throw 403 + @Transactional + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "adds user-specific vault keys", description = "Stores one or more user-vaultkey-tuples, as defined in the request body ({user1: token1, user2: token2, ...}).") + @APIResponse(responseCode = "200", description = "all keys stored") + @APIResponse(responseCode = "403", description = "not a vault owner") + @APIResponse(responseCode = "404", description = "at least one user has not been found") + @APIResponse(responseCode = "410", description = "vault is archived") + public Response grantAccess(@PathParam("vaultId") UUID vaultId, @NotEmpty Map tokens) { + var vault = Vault.findById(vaultId); // should always be found, since @VaultRole filter would have triggered + if (vault.archived) { + throw new GoneException("Vault is archived."); + } + + for (var entry : tokens.entrySet()) { + var userId = entry.getKey(); + var token = AccessToken.findById(new AccessToken.AccessId(userId, vaultId)); + if (token == null) { + token = new AccessToken(); + token.vault = vault; + token.user = User.findByIdOptional(userId).orElseThrow(NotFoundException::new); } + token.vaultKey = entry.getValue(); + token.persist(); + AuditEventVaultAccessGrant.log(jwt.getSubject(), vaultId, userId); } + return Response.ok().build(); } @GET @Path("/{vaultId}") @RolesAllowed("user") + // @VaultRole(VaultAccess.Role.MEMBER) // TODO: members and admin may do this... @Produces(MediaType.APPLICATION_JSON) @Transactional @Operation(summary = "gets a vault") @@ -339,6 +377,7 @@ public VaultDto get(@PathParam("vaultId") UUID vaultId) { @PUT @Path("/{vaultId}") @RolesAllowed("user") + @VaultRole(value = VaultAccess.Role.OWNER, onMissingVault = VaultRole.OnMissingVault.PASS) @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @Transactional @@ -348,39 +387,39 @@ public VaultDto get(@PathParam("vaultId") UUID vaultId) { @APIResponse(responseCode = "201", description = "new vault created") @APIResponse(responseCode = "402", description = "all seats in licence in use during creation of new vault") public Response createOrUpdate(@PathParam("vaultId") UUID vaultId, @Valid @NotNull VaultDto vaultDto) { - var currentUser = User.findById(jwt.getSubject()); - Vault vault; - boolean isCreated = false; - try { - vault = Vault.findByIdOptional(vaultId).get(); - } catch (NoSuchElementException _e) { + User currentUser = User.findById(jwt.getSubject()); + Optional existingVault = Vault.findByIdOptional(vaultId); + final Vault vault; + if (existingVault.isPresent()) { + // load existing vault: + vault = existingVault.get(); + } else { if (!EffectiveVaultAccess.isUserOccupyingSeat(currentUser.id)) { //for new vaults, we need to check that a licence seat is available if the user does not already have access to a vault. - var usedSeats = EffectiveVaultAccess.countEffectiveVaultUsers(); + var usedSeats = EffectiveVaultAccess.countSeatOccupyingUsers(); if (usedSeats >= license.getAvailableSeats()) { throw new PaymentRequiredException("Number of effective vault users exceeds available license seats"); } } - isCreated = true; - //create new vault + // create new vault: vault = new Vault(); vault.id = vaultDto.id; vault.creationTime = Instant.now().truncatedTo(ChronoUnit.MILLIS); - vault.masterkey = vaultDto.masterkey; - vault.iterations = vaultDto.iterations; - vault.salt = vaultDto.salt; - vault.authenticationPublicKey = vaultDto.authPublicKey; - vault.authenticationPrivateKey = vaultDto.authPrivateKey; - vault.directMembers.add(currentUser); } - //update new or existing vault + // set regardless of whether vault is new or existing: vault.name = vaultDto.name; vault.description = vaultDto.description; - vault.archived = isCreated ? false : vaultDto.archived; - vault.persistAndFlush(); - if (isCreated) { + vault.archived = existingVault.isEmpty() ? false : vaultDto.archived; + + vault.persistAndFlush(); // trigger PersistenceException before we continue with + if (existingVault.isEmpty()) { + var access = new VaultAccess(); + access.vault = vault; + access.authority = currentUser; + access.role = VaultAccess.Role.OWNER; + access.persist(); AuditEventVaultCreate.log(currentUser.id, vault.id, vault.name, vault.description); - AuditEventVaultMemberAdd.log(currentUser.id, vaultId, currentUser.id); + AuditEventVaultMemberAdd.log(currentUser.id, vaultId, currentUser.id, VaultAccess.Role.OWNER); return Response.created(URI.create(".")).contentLocation(URI.create(".")).entity(VaultDto.fromEntity(vault)).type(MediaType.APPLICATION_JSON).build(); } else { AuditEventVaultUpdate.log(currentUser.id, vault.id, vault.name, vault.description, vault.archived); @@ -388,13 +427,71 @@ public Response createOrUpdate(@PathParam("vaultId") UUID vaultId, @Valid @NotNu } } + @POST + @Path("/{vaultId}/claim-ownership") + @RolesAllowed("user") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Transactional + @Operation(summary = "claims ownership of a vault", + description = "Assigns the OWNER role to the currently logged in user, who proofs this claim by sending a JWT signed with a private key held by users knowing the Vault Admin Password") + @APIResponse(responseCode = "200", description = "ownership claimed successfully") + @APIResponse(responseCode = "400", description = "incorrect proof") + @APIResponse(responseCode = "404", description = "no such vault") + @APIResponse(responseCode = "409", description = "owned by another user") + public Response claimOwnership(@PathParam("vaultId") UUID vaultId, @FormParam("proof") @Valid @ValidJWS String proof) { + User currentUser = User.findById(jwt.getSubject()); + Vault vault = Vault.findByIdOptional(vaultId).orElseThrow(NotFoundException::new); + + // if vault.authenticationPublicKey no longer exists, this vault has already been claimed by a different user + var authPubKey = vault.getAuthenticationPublicKey().orElseThrow(() -> new ClientErrorException(Response.Status.CONFLICT)); + + try { + var verifier = JWT.require(Algorithm.ECDSA384(authPubKey)) + .acceptLeeway(30) + .withClaimPresence("nbf") + .withClaimPresence("exp") + .withSubject(currentUser.id) + .withClaim("vaultId", vaultId.toString().toLowerCase()) + .build(); + verifier.verify(proof); + } catch (JWTVerificationException e) { + throw new BadRequestException("Invalid proof of ownership", e); + } + + Optional existingAccess = VaultAccess.findByIdOptional(new VaultAccess.Id(vaultId, currentUser.id)); + if (existingAccess.isPresent()) { + var access = existingAccess.get(); + access.role = VaultAccess.Role.OWNER; + access.persist(); + AuditEventVaultMemberUpdate.log(currentUser.id, vaultId, currentUser.id, VaultAccess.Role.OWNER); + } else { + var access = new VaultAccess(); + access.vault = vault; + access.authority = currentUser; + access.role = VaultAccess.Role.OWNER; + access.persist(); + AuditEventVaultMemberAdd.log(currentUser.id, vaultId, currentUser.id, VaultAccess.Role.OWNER); + } + + vault.salt = null; + vault.iterations = null; + vault.masterkey = null; + vault.authenticationPrivateKey = null; + vault.authenticationPublicKey = null; + vault.persist(); + + AuditEventVaultOwnershipClaim.log(currentUser.id, vaultId); + return Response.ok(VaultDto.fromEntity(vault), MediaType.APPLICATION_JSON).build(); + } + public record VaultDto(@JsonProperty("id") UUID id, @JsonProperty("name") @NoHtmlOrScriptChars @NotBlank String name, @JsonProperty("description") @NoHtmlOrScriptChars String description, @JsonProperty("archived") boolean archived, @JsonProperty("creationTime") Instant creationTime, - @JsonProperty("masterkey") @OnlyBase64Chars String masterkey, @JsonProperty("iterations") int iterations, + // Legacy properties ("Vault Admin Password"): + @JsonProperty("masterkey") @OnlyBase64Chars String masterkey, @JsonProperty("iterations") Integer iterations, @JsonProperty("salt") @OnlyBase64Chars String salt, @JsonProperty("authPublicKey") @OnlyBase64Chars String authPublicKey, @JsonProperty("authPrivateKey") @OnlyBase64Chars String authPrivateKey ) { diff --git a/backend/src/main/java/org/cryptomator/hub/entities/AccessToken.java b/backend/src/main/java/org/cryptomator/hub/entities/AccessToken.java index da761c32c..daf432d51 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/AccessToken.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/AccessToken.java @@ -2,7 +2,6 @@ import io.quarkus.hibernate.orm.panache.PanacheEntityBase; import io.quarkus.panache.common.Parameters; -import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; @@ -20,78 +19,110 @@ import java.util.UUID; @Entity +@NamedQuery(name = "AccessToken.deleteByUser", query = "DELETE FROM AccessToken a WHERE a.id.userId = :userId") @Table(name = "access_token") -@NamedQuery(name = "AccessToken.get", query = """ - SELECT a - FROM Vault v - INNER JOIN v.effectiveMembers u - INNER JOIN u.devices d - INNER JOIN d.accessTokens a ON a.id.deviceId = d.id AND a.id.vaultId = v.id - WHERE v.id = :vaultId - AND u.id = :userId - AND d.id = :deviceId - """) -@RegisterForReflection(targets = {UUID[].class}) public class AccessToken extends PanacheEntityBase { @EmbeddedId public AccessId id = new AccessId(); @ManyToOne(optional = false, cascade = {CascadeType.REMOVE}) - @MapsId("deviceId") - @JoinColumn(name = "device_id") - public Device device; + @MapsId("userId") + @JoinColumn(name = "user_id") + public User user; @ManyToOne(optional = false, cascade = {CascadeType.REMOVE}) @MapsId("vaultId") @JoinColumn(name = "vault_id") public Vault vault; - @Column(name = "jwe", nullable = false) - public String jwe; + @Column(name = "vault_masterkey", nullable = false) + public String vaultKey; + + public static AccessToken unlock(UUID vaultId, String userId) { + /* + * FIXME remove this native query and add the named query again as soon as Hibernate ORM ships version 6.2.8 or 6.3.0 + * See https://github.com/quarkusio/quarkus/issues/35386 for further information + */ - public static AccessToken unlock(UUID vaultId, String deviceId, String userId) { try { - return find("#AccessToken.get", Parameters.with("deviceId", deviceId).and("vaultId", vaultId).and("userId", userId)).firstResult(); + var query = getEntityManager() + .createNativeQuery(""" + select + a1_0."user_id", + a1_0."vault_id", + u1_0."id", + u1_1."name", + u1_0."email", + u1_0."picture_url", + u1_0."privatekey", + u1_0."publickey", + u1_0."setupcode", + a1_0."vault_masterkey" + from + "user_details" u1_0 + join + "authority" u1_1 + on u1_0."id"=u1_1."id" + join + "effective_vault_access" e1_0 + on u1_0."id"=e1_0."authority_id" + join + "access_token" a1_0 + on u1_0."id"=a1_0."user_id" + and a1_0."vault_id"=:vaultId + and a1_0."user_id"=u1_0."id" + where + e1_0."vault_id"=:vaultId + and u1_0."id"=:userId + """, AccessToken.class) + .setParameter("vaultId", vaultId) + .setParameter("userId", userId); + return (AccessToken) query.getSingleResult(); } catch (NoResultException e) { return null; } } + + public static void deleteByUser(String userId) { + delete("#AccessToken.deleteByUser", Parameters.with("userId", userId)); + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; AccessToken other = (AccessToken) o; return Objects.equals(id, other.id) - && Objects.equals(device, other.device) + && Objects.equals(user, other.user) && Objects.equals(vault, other.vault) - && Objects.equals(jwe, other.jwe); + && Objects.equals(vaultKey, other.vaultKey); } @Override public int hashCode() { - return Objects.hash(id, device, vault, jwe); + return Objects.hash(id, user, vault, vaultKey); } @Override public String toString() { return "Access{" + "id=" + id + - ", device=" + device.id + + ", user=" + user.id + ", vault=" + vault.id + - ", jwe='" + jwe + '\'' + + ", vaultKey='" + vaultKey + '\'' + '}'; } @Embeddable public static class AccessId implements Serializable { - public String deviceId; + public String userId; public UUID vaultId; - public AccessId(String deviceId, UUID vaultId) { - this.deviceId = deviceId; + public AccessId(String userId, UUID vaultId) { + this.userId = userId; this.vaultId = vaultId; } @@ -103,19 +134,19 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; AccessId other = (AccessId) o; - return Objects.equals(deviceId, other.deviceId) // + return Objects.equals(userId, other.userId) // && Objects.equals(vaultId, other.vaultId); } @Override public int hashCode() { - return Objects.hash(deviceId, vaultId); + return Objects.hash(userId, vaultId); } @Override public String toString() { return "AccessId{" + - "deviceId='" + deviceId + '\'' + + "userId='" + userId + '\'' + ", vaultId='" + vaultId + '\'' + '}'; } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultAccessGrant.java b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultAccessGrant.java index 545d596e8..f1ad2a242 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultAccessGrant.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultAccessGrant.java @@ -1,6 +1,5 @@ package org.cryptomator.hub.entities; -import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; @@ -13,7 +12,6 @@ @Entity @Table(name = "audit_event_vault_access_grant") @DiscriminatorValue(AuditEventVaultAccessGrant.TYPE) -@RegisterForReflection(targets = {UUID[].class}) public class AuditEventVaultAccessGrant extends AuditEvent { public static final String TYPE = "VAULT_ACCESS_GRANT"; diff --git a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultCreate.java b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultCreate.java index 37e25cd6b..a0c60a88b 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultCreate.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultCreate.java @@ -1,6 +1,5 @@ package org.cryptomator.hub.entities; -import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; @@ -13,7 +12,6 @@ @Entity @Table(name = "audit_event_vault_create") @DiscriminatorValue(AuditEventVaultCreate.TYPE) -@RegisterForReflection(targets = {UUID[].class}) public class AuditEventVaultCreate extends AuditEvent { public static final String TYPE = "VAULT_CREATE"; diff --git a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultKeyRetrieve.java b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultKeyRetrieve.java index 7dd73d905..a1daf8add 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultKeyRetrieve.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultKeyRetrieve.java @@ -1,6 +1,5 @@ package org.cryptomator.hub.entities; -import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; @@ -15,7 +14,6 @@ @Entity @Table(name = "audit_event_vault_key_retrieve") @DiscriminatorValue(AuditEventVaultKeyRetrieve.TYPE) -@RegisterForReflection(targets = {UUID[].class}) public class AuditEventVaultKeyRetrieve extends AuditEvent { public static final String TYPE = "VAULT_KEY_RETRIEVE"; diff --git a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberAdd.java b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberAdd.java index 1239efb85..9a8b23b06 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberAdd.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberAdd.java @@ -1,9 +1,10 @@ package org.cryptomator.hub.entities; -import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Table; import java.time.Instant; @@ -13,7 +14,6 @@ @Entity @Table(name = "audit_event_vault_member_add") @DiscriminatorValue(AuditEventVaultMemberAdd.TYPE) -@RegisterForReflection(targets = {UUID[].class}) public class AuditEventVaultMemberAdd extends AuditEvent { public static final String TYPE = "VAULT_MEMBER_ADD"; @@ -27,6 +27,10 @@ public class AuditEventVaultMemberAdd extends AuditEvent { @Column(name = "authority_id") public String authorityId; + @Column(name = "role", nullable = false) + @Enumerated(EnumType.STRING) + public VaultAccess.Role role; + @Override public boolean equals(Object o) { if (this == o) return true; @@ -35,20 +39,22 @@ public boolean equals(Object o) { return super.equals(that) // && Objects.equals(addedBy, that.addedBy) // && Objects.equals(vaultId, that.vaultId) // - && Objects.equals(authorityId, that.authorityId); + && Objects.equals(authorityId, that.authorityId) // + && Objects.equals(role, that.role); } @Override public int hashCode() { - return Objects.hash(id, addedBy, vaultId, authorityId); + return Objects.hash(id, addedBy, vaultId, authorityId, role); } - public static void log(String addedBy, UUID vaultId, String authorityId) { + public static void log(String addedBy, UUID vaultId, String authorityId, VaultAccess.Role role) { var event = new AuditEventVaultMemberAdd(); event.timestamp = Instant.now(); event.addedBy = addedBy; event.vaultId = vaultId; event.authorityId = authorityId; + event.role = role; event.persist(); } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberRemove.java b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberRemove.java index 559131550..e947d155c 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberRemove.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberRemove.java @@ -1,6 +1,5 @@ package org.cryptomator.hub.entities; -import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; @@ -13,7 +12,6 @@ @Entity @Table(name = "audit_event_vault_member_remove") @DiscriminatorValue(AuditEventVaultMemberRemove.TYPE) -@RegisterForReflection(targets = {UUID[].class}) public class AuditEventVaultMemberRemove extends AuditEvent { public static final String TYPE = "VAULT_MEMBER_REMOVE"; diff --git a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberUpdate.java b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberUpdate.java new file mode 100644 index 000000000..cc0f29883 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberUpdate.java @@ -0,0 +1,61 @@ +package org.cryptomator.hub.entities; + +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "audit_event_vault_member_update") +@DiscriminatorValue(AuditEventVaultMemberUpdate.TYPE) +public class AuditEventVaultMemberUpdate extends AuditEvent { + + public static final String TYPE = "VAULT_MEMBER_UPDATE"; + + @Column(name = "updated_by") + public String updatedBy; + + @Column(name = "vault_id") + public UUID vaultId; + + @Column(name = "authority_id") + public String authorityId; + + @Column(name = "role", nullable = false) + @Enumerated(EnumType.STRING) + public VaultAccess.Role role; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AuditEventVaultMemberUpdate that = (AuditEventVaultMemberUpdate) o; + return super.equals(that) // + && Objects.equals(updatedBy, that.updatedBy) // + && Objects.equals(vaultId, that.vaultId) // + && Objects.equals(authorityId, that.authorityId) // + && Objects.equals(role, that.role); + } + + @Override + public int hashCode() { + return Objects.hash(id, updatedBy, vaultId, authorityId, role); + } + + public static void log(String updatedBy, UUID vaultId, String authorityId, VaultAccess.Role role) { + var event = new AuditEventVaultMemberUpdate(); + event.timestamp = Instant.now(); + event.updatedBy = updatedBy; + event.vaultId = vaultId; + event.authorityId = authorityId; + event.role = role; + event.persist(); + } + +} diff --git a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultOwnershipClaim.java b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultOwnershipClaim.java new file mode 100644 index 000000000..ee5fd1072 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultOwnershipClaim.java @@ -0,0 +1,48 @@ +package org.cryptomator.hub.entities; + +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "audit_event_vault_ownership_claim") +@DiscriminatorValue(AuditEventVaultOwnershipClaim.TYPE) +public class AuditEventVaultOwnershipClaim extends AuditEvent { + + public static final String TYPE = "VAULT_OWNERSHIP_CLAIM"; + + @Column(name = "claimed_by") + public String claimedBy; + + @Column(name = "vault_id") + public UUID vaultId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AuditEventVaultOwnershipClaim that = (AuditEventVaultOwnershipClaim) o; + return super.equals(that) // + && Objects.equals(claimedBy, that.claimedBy) // + && Objects.equals(vaultId, that.vaultId); + } + + @Override + public int hashCode() { + return Objects.hash(id, claimedBy, vaultId); + } + + public static void log(String claimedBy, UUID vaultId) { + var event = new AuditEventVaultOwnershipClaim(); + event.timestamp = Instant.now(); + event.claimedBy = claimedBy; + event.vaultId = vaultId; + event.persist(); + } + +} diff --git a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultUpdate.java b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultUpdate.java index e29ff1adc..a2f857912 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultUpdate.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultUpdate.java @@ -1,6 +1,5 @@ package org.cryptomator.hub.entities; -import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; @@ -13,7 +12,6 @@ @Entity @Table(name = "audit_event_vault_update") @DiscriminatorValue(AuditEventVaultUpdate.TYPE) -@RegisterForReflection(targets = {UUID[].class}) public class AuditEventVaultUpdate extends AuditEvent { public static final String TYPE = "VAULT_UPDATE"; diff --git a/backend/src/main/java/org/cryptomator/hub/entities/Authority.java b/backend/src/main/java/org/cryptomator/hub/entities/Authority.java index 26c2bd0ca..d9dc1600a 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/Authority.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/Authority.java @@ -5,18 +5,14 @@ import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorColumn; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.Inheritance; import jakarta.persistence.InheritanceType; import jakarta.persistence.NamedQuery; -import jakarta.persistence.OneToMany; import jakarta.persistence.Table; -import java.util.HashSet; import java.util.List; import java.util.Objects; -import java.util.Set; import java.util.stream.Stream; @Entity @@ -35,15 +31,12 @@ WHERE LOWER(a.name) LIKE :name FROM Authority a WHERE a.id IN :ids """) -public class Authority extends PanacheEntityBase { +public class Authority extends PanacheEntityBase { // TODO make sealed? @Id @Column(name = "id", nullable = false) public String id; - @OneToMany(mappedBy = "owner", orphanRemoval = true, fetch = FetchType.LAZY) - public Set devices = new HashSet<>(); - @Column(name = "name", nullable = false) public String name; @@ -59,7 +52,6 @@ public static Stream findAllInList(List ids) { public String toString() { return "Authority{" + "id='" + id + '\'' + - ", devices=" + devices.size() + ", name='" + name + '\'' + '}'; } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/Device.java b/backend/src/main/java/org/cryptomator/hub/entities/Device.java index 75b803790..94e37a9e8 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/Device.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/Device.java @@ -11,29 +11,20 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.NamedQuery; -import jakarta.persistence.OneToMany; +import jakarta.persistence.NoResultException; import jakarta.persistence.Table; import java.time.Instant; -import java.util.HashSet; import java.util.List; import java.util.Objects; -import java.util.Set; -import java.util.UUID; import java.util.stream.Stream; @Entity @Table(name = "device") -@NamedQuery(name = "Device.requiringAccessGrant", - query = """ - SELECT d - FROM Vault v - INNER JOIN v.effectiveMembers m - INNER JOIN m.devices d - LEFT JOIN d.accessTokens a ON a.id.vaultId = :vaultId AND a.id.deviceId = d.id - WHERE v.id = :vaultId AND a.vault IS NULL - """ +@NamedQuery(name = "Device.findByIdAndOwner", + query = "SELECT d FROM Device d WHERE d.id = :deviceId AND d.owner.id = :userId" ) +@NamedQuery(name = "Device.deleteByOwner", query = "DELETE FROM Device d WHERE d.owner.id = :userId") @NamedQuery(name = "Device.allInList", query = """ SELECT d @@ -52,10 +43,7 @@ public enum Type { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "owner_id", updatable = false, nullable = false) - public Authority owner; - - @OneToMany(mappedBy = "device", fetch = FetchType.LAZY) - public Set accessTokens = new HashSet<>(); + public User owner; @Column(name = "name", nullable = false) public String name; @@ -67,6 +55,9 @@ public enum Type { @Column(name = "publickey", nullable = false) public String publickey; + @Column(name = "user_privatekey", nullable = false) + public String userPrivateKey; + @Column(name = "creation_time", nullable = false) public Instant creationTime; @@ -78,6 +69,8 @@ public String toString() { ", name='" + name + '\'' + ", type='" + type + '\'' + ", publickey='" + publickey + '\'' + + ", userPrivateKey='" + userPrivateKey + '\'' + + ", creationTime='" + creationTime + '\'' + '}'; } @@ -90,19 +83,26 @@ public boolean equals(Object o) { && Objects.equals(this.owner, other.owner) && Objects.equals(this.name, other.name) && Objects.equals(this.type, other.type) - && Objects.equals(this.publickey, other.publickey); + && Objects.equals(this.publickey, other.publickey) + && Objects.equals(this.userPrivateKey, other.userPrivateKey) + && Objects.equals(this.creationTime, other.creationTime); } @Override public int hashCode() { - return Objects.hash(id, owner, name, type, publickey); + return Objects.hash(id, owner, name, type, publickey, userPrivateKey, creationTime); } - public static Stream findRequiringAccessGrant(UUID vaultId) { - return find("#Device.requiringAccessGrant", Parameters.with("vaultId", vaultId)).stream(); + public static Device findByIdAndUser(String deviceId, String userId) throws NoResultException { + return find("#Device.findByIdAndOwner", Parameters.with("deviceId", deviceId).and("userId", userId)).singleResult(); } public static Stream findAllInList(List ids) { return find("#Device.allInList", Parameters.with("ids", ids)).stream(); } + + public static void deleteByOwner(String userId) { + delete("#Device.deleteByOwner", Parameters.with("userId", userId)); + } + } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/EffectiveGroupMembership.java b/backend/src/main/java/org/cryptomator/hub/entities/EffectiveGroupMembership.java index 4af1ffbb5..fbcf90952 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/EffectiveGroupMembership.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/EffectiveGroupMembership.java @@ -12,6 +12,7 @@ import java.io.Serializable; import java.util.Objects; +import java.util.stream.Stream; @Entity @Immutable @@ -22,6 +23,12 @@ SELECT count( DISTINCT u) INNER JOIN EffectiveGroupMembership egm ON u.id = egm.id.memberId WHERE egm.id.groupId = :groupId """) +@NamedQuery(name = "EffectiveGroupMembership.getEGUs", query = """ + SELECT DISTINCT u + FROM User u + INNER JOIN EffectiveGroupMembership egm ON u.id = egm.id.memberId + WHERE egm.id.groupId = :groupId + """) public class EffectiveGroupMembership extends PanacheEntityBase { @EmbeddedId @@ -33,6 +40,10 @@ public static long countEffectiveGroupUsers(String groupdId) { return EffectiveGroupMembership.count("#EffectiveGroupMembership.countEGUs", Parameters.with("groupId", groupdId)); } + public static Stream getEffectiveGroupUsers(String groupdId) { + return EffectiveGroupMembership.find("#EffectiveGroupMembership.getEGUs", Parameters.with("groupId", groupdId)).stream(); + } + @Embeddable public static class EffectiveGroupMembershipId implements Serializable { diff --git a/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java b/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java index 8d4826119..a9ad708cf 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java @@ -2,59 +2,75 @@ import io.quarkus.hibernate.orm.panache.PanacheEntityBase; import io.quarkus.panache.common.Parameters; -import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import jakarta.persistence.EmbeddedId; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.NamedQuery; import jakarta.persistence.Table; import org.hibernate.annotations.Immutable; import java.io.Serializable; +import java.util.Collection; import java.util.Objects; import java.util.UUID; +import java.util.stream.Collectors; @Entity @Immutable @Table(name = "effective_vault_access") -@NamedQuery(name = "EffectiveVaultAccess.countVaultAccessesOfUser", query = """ - SELECT count(eva) - FROM EffectiveVaultAccess eva - WHERE eva.id.authorityId = :userId +@NamedQuery(name = "EffectiveVaultAccess.countSeatsOccupiedByUser", query = """ + SELECT count(eva) + FROM EffectiveVaultAccess eva + INNER JOIN Vault v ON eva.id.vaultId = v.id AND NOT v.archived + WHERE eva.id.authorityId = :userId """) -@NamedQuery(name = "EffectiveVaultAccess.countEVUs", query = """ - SELECT count( DISTINCT u) - FROM User u - INNER JOIN EffectiveVaultAccess eva ON u.id = eva.id.authorityId +@NamedQuery(name = "EffectiveVaultAccess.countSeatOccupyingUsers", query = """ + SELECT count(DISTINCT u) + FROM User u + INNER JOIN EffectiveVaultAccess eva ON u.id = eva.id.authorityId + INNER JOIN Vault v ON eva.id.vaultId = v.id AND NOT v.archived """) -@NamedQuery(name = "EffectiveVaultAccess.countEVUsInGroup", query = """ - SELECT count( DISTINCT u) - FROM User u - INNER JOIN EffectiveVaultAccess eva ON u.id = eva.id.authorityId - INNER JOIN EffectiveGroupMembership egm ON u.id = egm.id.memberId - WHERE egm.id.groupId = :groupId +@NamedQuery(name = "EffectiveVaultAccess.countSeatOccupyingUsersOfGroup", query = """ + SELECT count(DISTINCT u) + FROM User u + INNER JOIN EffectiveVaultAccess eva ON u.id = eva.id.authorityId + INNER JOIN EffectiveGroupMembership egm ON u.id = egm.id.memberId + INNER JOIN Vault v ON eva.id.vaultId = v.id AND NOT v.archived + WHERE egm.id.groupId = :groupId + """) +@NamedQuery(name = "EffectiveVaultAccess.findByUserAndVault", query = """ + SELECT eva + FROM EffectiveVaultAccess eva + WHERE eva.id.vaultId = :vaultId AND eva.id.authorityId = :authorityId """) -@RegisterForReflection(targets = {UUID[].class}) public class EffectiveVaultAccess extends PanacheEntityBase { @EmbeddedId - public EffectiveVaultAccessId id; + public EffectiveVaultAccess.Id id; public static boolean isUserOccupyingSeat(String userId) { - return EffectiveVaultAccess.count("#EffectiveVaultAccess.countVaultAccessesOfUser", Parameters.with("userId", userId)) > 0; + return EffectiveVaultAccess.count("#EffectiveVaultAccess.countSeatsOccupiedByUser", Parameters.with("userId", userId)) > 0; + } + + public static long countSeatOccupyingUsers() { + return EffectiveVaultAccess.count("#EffectiveVaultAccess.countSeatOccupyingUsers"); } - public static long countEffectiveVaultUsers() { - return EffectiveVaultAccess.count("#EffectiveVaultAccess.countEVUs"); + public static long countSeatOccupyingUsersOfGroup(String groupId) { + return EffectiveVaultAccess.count("#EffectiveVaultAccess.countSeatOccupyingUsersOfGroup", Parameters.with("groupId", groupId)); } - public static long countEffectiveVaultUsersOfGroup(String groupId) { - return EffectiveVaultAccess.count("#EffectiveVaultAccess.countEVUsInGroup", Parameters.with("groupId", groupId)); + public static Collection listRoles(UUID vaultId, String authorityId) { + return EffectiveVaultAccess.find("#EffectiveVaultAccess.findByUserAndVault", Parameters.with("vaultId", vaultId).and("authorityId", authorityId)).stream() + .map(eva -> eva.id.role) + .collect(Collectors.toUnmodifiableSet()); } @Embeddable - public static class EffectiveVaultAccessId implements Serializable { + public static class Id implements Serializable { @Column(name = "vault_id") public UUID vaultId; @@ -62,26 +78,39 @@ public static class EffectiveVaultAccessId implements Serializable { @Column(name = "authority_id") public String authorityId; + @Column(name = "role", nullable = false) + @Enumerated(EnumType.STRING) + public VaultAccess.Role role; + + public Id(UUID vaultId, String authorityId, VaultAccess.Role role) { + this.vaultId = vaultId; + this.authorityId = authorityId; + this.role = role; + } + + public Id() { + } + @Override public boolean equals(Object o) { if (this == o) return true; - if (o instanceof EffectiveVaultAccessId evaId) { - return Objects.equals(vaultId, evaId.vaultId) // - && Objects.equals(authorityId, evaId.authorityId); + if (o instanceof EffectiveVaultAccess.Id other) { + return Objects.equals(this.vaultId, other.vaultId) && Objects.equals(this.authorityId, other.authorityId) && Objects.equals(this.role, other.role); } return false; } @Override public int hashCode() { - return Objects.hash(vaultId, authorityId); + return Objects.hash(vaultId, authorityId, role); } @Override public String toString() { - return "EffectiveVaultAccessId{" + + return "EffectiveVaultAccess.Id{" + "vaultId='" + vaultId + '\'' + ", authorityId='" + authorityId + '\'' + + ", role='" + role + '\'' + '}'; } } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/LegacyAccessToken.java b/backend/src/main/java/org/cryptomator/hub/entities/LegacyAccessToken.java new file mode 100644 index 000000000..782f804db --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/entities/LegacyAccessToken.java @@ -0,0 +1,107 @@ +package org.cryptomator.hub.entities; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.NamedNativeQuery; +import jakarta.persistence.NoResultException; +import jakarta.persistence.Table; + +import java.io.Serializable; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "access_token_legacy") +@NamedNativeQuery(name = "LegacyAccessToken.get", resultClass = LegacyAccessToken.class, query = """ + SELECT t.device_id, t.vault_id, t.jwe + FROM access_token_legacy t + INNER JOIN device_legacy d ON d.id = t.device_id + INNER JOIN effective_vault_access a ON a.vault_id = t.vault_id AND a.authority_id = d.owner_id + WHERE t.vault_id = :vaultId AND d.id = :deviceId AND d.owner_id = :userId + """) +@Deprecated +public class LegacyAccessToken extends PanacheEntityBase { + + @EmbeddedId + public AccessId id = new AccessId(); + + @Column(name = "jwe", nullable = false) + public String jwe; + + public static LegacyAccessToken unlock(UUID vaultId, String deviceId, String userId) { + try { + return getEntityManager().createNamedQuery("LegacyAccessToken.get", LegacyAccessToken.class) // + .setParameter("deviceId", deviceId) // + .setParameter("vaultId", vaultId) // + .setParameter("userId", userId) // + .getSingleResult(); + } catch (NoResultException e) { + return null; + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LegacyAccessToken other = (LegacyAccessToken) o; + return Objects.equals(id, other.id) + && Objects.equals(jwe, other.jwe); + } + + @Override + public int hashCode() { + return Objects.hash(id, jwe); + } + + @Override + public String toString() { + return "LegacyAccessToken{" + + "id=" + id + + ", jwe='" + jwe + '\'' + + '}'; + } + + @Embeddable + public static class AccessId implements Serializable { + + @Column(name = "device_id", nullable = false) + public String deviceId; + + @Column(name = "vault_id", nullable = false) + public UUID vaultId; + + public AccessId(String deviceId, UUID vaultId) { + this.deviceId = deviceId; + this.vaultId = vaultId; + } + + public AccessId() { + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AccessId other = (AccessId) o; + return Objects.equals(deviceId, other.deviceId) // + && Objects.equals(vaultId, other.vaultId); + } + + @Override + public int hashCode() { + return Objects.hash(deviceId, vaultId); + } + + @Override + public String toString() { + return "LegacyAccessTokenId{" + + "deviceId='" + deviceId + '\'' + + ", vaultId='" + vaultId + '\'' + + '}'; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/cryptomator/hub/entities/LegacyDevice.java b/backend/src/main/java/org/cryptomator/hub/entities/LegacyDevice.java new file mode 100644 index 000000000..1769013b4 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/entities/LegacyDevice.java @@ -0,0 +1,18 @@ +package org.cryptomator.hub.entities; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Deprecated +@Entity +@Table(name = "device_legacy") +public class LegacyDevice extends PanacheEntityBase { + + @Id + @Column(name = "id", nullable = false) + public String id; + +} diff --git a/backend/src/main/java/org/cryptomator/hub/entities/User.java b/backend/src/main/java/org/cryptomator/hub/entities/User.java index 73515dc84..927750504 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/User.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/User.java @@ -1,15 +1,32 @@ package org.cryptomator.hub.entities; +import io.quarkus.panache.common.Parameters; import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.util.HashSet; import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; @Entity @Table(name = "user_details") @DiscriminatorValue("USER") +@NamedQuery(name = "User.requiringAccessGrant", + query = """ + SELECT u + FROM User u + INNER JOIN EffectiveVaultAccess perm ON u.id = perm.id.authorityId + LEFT JOIN u.accessTokens token ON token.id.vaultId = :vaultId AND token.id.userId = u.id + WHERE perm.id.vaultId = :vaultId AND token.vault IS NULL AND u.publicKey IS NOT NULL + """ +) public class User extends Authority { @Column(name = "picture_url") @@ -18,6 +35,21 @@ public class User extends Authority { @Column(name = "email") public String email; + @Column(name = "publickey") + public String publicKey; + + @Column(name = "privatekey") + public String privateKey; + + @Column(name = "setupcode") + public String setupCode; + + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) + public Set accessTokens = new HashSet<>(); + + @OneToMany(mappedBy = "owner", orphanRemoval = true, fetch = FetchType.LAZY) + public Set devices = new HashSet<>(); + @Override public boolean equals(Object o) { if (this == o) return true; @@ -25,12 +57,19 @@ public boolean equals(Object o) { User that = (User) o; return super.equals(that) // && Objects.equals(pictureUrl, that.pictureUrl) // - && Objects.equals(email, that.email); + && Objects.equals(email, that.email) // + && Objects.equals(publicKey, that.publicKey) // + && Objects.equals(privateKey, that.privateKey) // + && Objects.equals(setupCode, that.setupCode); } @Override public int hashCode() { - return Objects.hash(id, pictureUrl, email); + return Objects.hash(id, pictureUrl, email, publicKey, privateKey, setupCode); + } + + public static Stream findRequiringAccessGrant(UUID vaultId) { + return find("#User.requiringAccessGrant", Parameters.with("vaultId", vaultId)).stream(); } } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/Vault.java b/backend/src/main/java/org/cryptomator/hub/entities/Vault.java index f969d8727..682b17e10 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/Vault.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/Vault.java @@ -2,7 +2,6 @@ import io.quarkus.hibernate.orm.panache.PanacheEntityBase; import io.quarkus.panache.common.Parameters; -import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -15,10 +14,17 @@ import jakarta.persistence.Table; import org.hibernate.annotations.Immutable; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; import java.time.Instant; +import java.util.Base64; import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -30,9 +36,14 @@ query = """ SELECT DISTINCT v FROM Vault v - LEFT JOIN v.effectiveMembers m - WHERE m.id = :userId - AND NOT v.archived + INNER JOIN EffectiveVaultAccess a ON a.id.vaultId = v.id AND a.id.authorityId = :userId + """) +@NamedQuery(name = "Vault.accessibleByUserAndRole", + query = """ + SELECT DISTINCT v + FROM Vault v + INNER JOIN EffectiveVaultAccess a ON a.id.vaultId = v.id AND a.id.authorityId = :userId + WHERE a.id.role = :role """) @NamedQuery(name = "Vault.allInList", query = """ @@ -41,7 +52,6 @@ WHERE v.id IN :ids """ ) -@RegisterForReflection(targets = {UUID[].class}) public class Vault extends PanacheEntityBase { @Id @@ -49,6 +59,7 @@ public class Vault extends PanacheEntityBase { public UUID id; @ManyToMany + @Immutable @JoinTable(name = "vault_access", joinColumns = @JoinColumn(name = "vault_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "authority_id", referencedColumnName = "id") @@ -64,24 +75,24 @@ public class Vault extends PanacheEntityBase { public Set effectiveMembers = new HashSet<>(); @OneToMany(mappedBy = "vault", fetch = FetchType.LAZY) - public Set accessTokens = new HashSet<>(); // rename to accesstokens? + public Set accessTokens = new HashSet<>(); @Column(name = "name", nullable = false) public String name; - @Column(name = "salt", nullable = false) + @Column(name = "salt") public String salt; - @Column(name = "iterations", nullable = false) - public int iterations; + @Column(name = "iterations") + public Integer iterations; - @Column(name = "masterkey", nullable = false) + @Column(name = "masterkey") public String masterkey; - @Column(name = "auth_pubkey", nullable = false) + @Column(name = "auth_pubkey") public String authenticationPublicKey; - @Column(name = "auth_prvkey", nullable = false) + @Column(name = "auth_prvkey") public String authenticationPrivateKey; @Column(name = "creation_time", nullable = false) @@ -93,10 +104,35 @@ public class Vault extends PanacheEntityBase { @Column(name = "archived", nullable = false) public boolean archived; + public Optional getAuthenticationPublicKey() { + if (authenticationPublicKey == null) { + return Optional.empty(); + } + + try { + var publicKeySpec = new X509EncodedKeySpec(Base64.getDecoder().decode(authenticationPublicKey)); + var keyFactory = KeyFactory.getInstance("EC"); + var key = keyFactory.generatePublic(publicKeySpec); + if (key instanceof ECPublicKey k) { + return Optional.of(k); + } else { + return Optional.empty(); + } + } catch (InvalidKeySpecException e) { + return Optional.empty(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + public static Stream findAccessibleByUser(String userId) { return find("#Vault.accessibleByUser", Parameters.with("userId", userId)).stream(); } + public static Stream findAccessibleByUser(String userId, VaultAccess.Role role) { + return find("#Vault.accessibleByUserAndRole", Parameters.with("userId", userId).and("role", role)).stream(); + } + public static Stream findAllInList(List ids) { return find("#Vault.allInList", Parameters.with("ids", ids)).stream(); } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/VaultAccess.java b/backend/src/main/java/org/cryptomator/hub/entities/VaultAccess.java new file mode 100644 index 000000000..132040b84 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/entities/VaultAccess.java @@ -0,0 +1,106 @@ +package org.cryptomator.hub.entities; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import io.quarkus.panache.common.Parameters; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.Table; + +import java.io.Serializable; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Stream; + +@Entity +@Table(name = "vault_access") +@NamedQuery(name = "VaultAccess.forVault", + query = """ + SELECT va + FROM VaultAccess va + INNER JOIN FETCH va.vault + INNER JOIN FETCH va.authority + WHERE va.id.vaultId = :vaultId + """) +public class VaultAccess extends PanacheEntityBase { + + @EmbeddedId + public VaultAccess.Id id = new VaultAccess.Id(); + + @ManyToOne + @MapsId("vaultId") + @JoinColumn(name = "vault_id") + public Vault vault; + + @ManyToOne + @MapsId("authorityId") + @JoinColumn(name = "authority_id") + public Authority authority; + + @Column(name = "role", nullable = false) + @Enumerated(EnumType.STRING) + public Role role; + + public enum Role { + /** + * User with access to vault contents. + */ + MEMBER, + + /** + * User with administrative privileges on a vault. + */ + OWNER + } + + public static Stream forVault(UUID vaultId) { + return find("#VaultAccess.forVault", Parameters.with("vaultId", vaultId)).stream(); + } + + @Embeddable + public static class Id implements Serializable { + + @Column(name = "vault_id") + public UUID vaultId; + + @Column(name = "authority_id") + public String authorityId; + + public Id(UUID vaultId, String authorityId) { + this.vaultId = vaultId; + this.authorityId = authorityId; + } + + public Id() { + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o instanceof Id other) { + return Objects.equals(this.vaultId, other.vaultId) && Objects.equals(this.authorityId, other.authorityId); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(vaultId, authorityId); + } + + @Override + public String toString() { + return "VaultAccess.Id{" + + "vaultId='" + vaultId + '\'' + + ", authorityId='" + authorityId + '\'' + + '}'; + } + } +} diff --git a/backend/src/main/java/org/cryptomator/hub/filters/VaultAdminOnlyFilter.java b/backend/src/main/java/org/cryptomator/hub/filters/VaultAdminOnlyFilter.java deleted file mode 100644 index 0d6aa4dde..000000000 --- a/backend/src/main/java/org/cryptomator/hub/filters/VaultAdminOnlyFilter.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.cryptomator.hub.filters; - -import jakarta.ws.rs.NameBinding; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@NameBinding -@Target({ElementType.TYPE, ElementType.METHOD}) -@Retention(value = RetentionPolicy.RUNTIME) -public @interface VaultAdminOnlyFilter { -} diff --git a/backend/src/main/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProvider.java b/backend/src/main/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProvider.java deleted file mode 100644 index c2c6dad1d..000000000 --- a/backend/src/main/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProvider.java +++ /dev/null @@ -1,139 +0,0 @@ -package org.cryptomator.hub.filters; - -import com.auth0.jwt.JWT; -import com.auth0.jwt.RegisteredClaims; -import com.auth0.jwt.algorithms.Algorithm; -import com.auth0.jwt.exceptions.IncorrectClaimException; -import com.auth0.jwt.exceptions.JWTDecodeException; -import com.auth0.jwt.exceptions.JWTVerificationException; -import com.auth0.jwt.interfaces.DecodedJWT; -import com.auth0.jwt.interfaces.JWTVerifier; -import com.auth0.jwt.interfaces.Verification; -import io.quarkus.runtime.annotations.RegisterForReflection; -import jakarta.ws.rs.NotFoundException; -import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.container.ContainerRequestFilter; -import jakarta.ws.rs.ext.Provider; -import org.cryptomator.hub.entities.Vault; - -import java.security.KeyFactory; -import java.security.NoSuchAlgorithmException; -import java.security.interfaces.ECPublicKey; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.X509EncodedKeySpec; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Base64; -import java.util.Date; -import java.util.UUID; - -@Provider -@VaultAdminOnlyFilter -@RegisterForReflection(targets = {UUID[].class}) -public class VaultAdminOnlyFilterProvider implements ContainerRequestFilter { - - public static final String VAULT_ADMIN_AUTHORIZATION = "Cryptomator-Vault-Admin-Authorization"; - static final String VAULT_ID = "vaultId"; - static final int REQUEST_LEEWAY_IN_SECONDS = 15; - - @Override - public void filter(ContainerRequestContext containerRequestContext) { - var vaultIdQueryParameter = getVaultIdQueryParameter(containerRequestContext); - var vaultAdminAuthorizationJWT = getUnverifiedvaultAdminAuthorizationJWT(containerRequestContext); - var unveridifedVaultId = getUnverifiedVaultId(vaultAdminAuthorizationJWT); - if (vaultIdQueryParameter.equals(unveridifedVaultId)) { - var vault = Vault.findByIdOptional(UUID.fromString(unveridifedVaultId)).orElseThrow(NotFoundException::new); - var algorithm = Algorithm.ECDSA384(decodePublicKey(vault.authenticationPublicKey)); - verify(buildVerifier(algorithm), vaultAdminAuthorizationJWT); - } else { - throw new VaultAdminValidationFailedException("Other vaultId provided"); - } - } - - //visible for testing - void verify(JWTVerifier verifier, DecodedJWT vaultAdminAuthorizationJWT) { - try { - verifier.verify(vaultAdminAuthorizationJWT); - } catch (IncorrectClaimException e) { - if (e.getClaimName().equals(RegisteredClaims.ISSUED_AT)) { - throw new VaultAdminTokenIAPNotValidException("ISSUED_AT claim of VaultAdminAuthorizationJWT not provided or not yet or no longer valid"); - } else { - throw new VaultAdminValidationFailedException("Incorrect claim exception"); - } - } catch (JWTVerificationException e) { - throw new VaultAdminValidationFailedException("Different key used to sign the VaultAdminAuthorizationJWT"); - } - } - - //visible for testing - JWTVerifier buildVerifier(Algorithm algorithm) { - return verification(algorithm).build(); - } - - private Verification verification(Algorithm algorithm) { - return verification(algorithm, Instant.now()); - } - - //visible for testing - Verification verification(Algorithm algorithm, Instant now) { - return JWT.require(algorithm) // - .withClaim(RegisteredClaims.ISSUED_AT, (claim, jwt) -> jwt.getIssuedAt() != null // - && !jwt.getIssuedAt().before(Date.from(now.minus(REQUEST_LEEWAY_IN_SECONDS, ChronoUnit.SECONDS))) // - && !jwt.getIssuedAt().after(Date.from(now.plus(REQUEST_LEEWAY_IN_SECONDS, ChronoUnit.SECONDS)))) // - .ignoreIssuedAt(); - } - - //visible for testing - String getVaultIdQueryParameter(ContainerRequestContext containerRequestContext) { - var vauldIdQueryParameters = containerRequestContext.getUriInfo().getPathParameters().get(VAULT_ID); - if (vauldIdQueryParameters == null || vauldIdQueryParameters.size() != 1) { - throw new VaultAdminValidationFailedException("VaultId not provided"); - } - return vauldIdQueryParameters.get(0); - } - - //visible for testing - DecodedJWT getUnverifiedvaultAdminAuthorizationJWT(ContainerRequestContext containerRequestContext) { - var clientJwt = containerRequestContext.getHeaderString(VAULT_ADMIN_AUTHORIZATION); - if (clientJwt != null) { - try { - return JWT.decode(clientJwt); - } catch (JWTDecodeException e) { - throw new VaultAdminValidationFailedException("Malformed VaultAdminAuthorizationJWT provided"); - } - } else { - throw new VaultAdminNotProvidedException("VaultAdminAuthorizationJWT not provided"); - } - } - - //visible for testing - String getUnverifiedVaultId(DecodedJWT vaultAdminAuthorizationJWT) { - var unveridifedVaultId = vaultAdminAuthorizationJWT.getHeaderClaim(VAULT_ID); - if (!unveridifedVaultId.isNull() && unveridifedVaultId.asString() != null) { // TODO should verify uuid as well - return unveridifedVaultId.asString(); - } else { - throw new VaultAdminValidationFailedException("No VaultAdminAuthorizationJWT provided"); - } - } - - //visible for testing - static ECPublicKey decodePublicKey(String pemEncodedPublicKey) { - try { - var publicKeySpec = new X509EncodedKeySpec(Base64.getDecoder().decode(pemEncodedPublicKey)); - - var keyFactory = KeyFactory.getInstance("EC"); - var key = keyFactory.generatePublic(publicKeySpec); - - if (key instanceof ECPublicKey k) { - return k; - } else { - throw new IllegalStateException("Key not an EC public key."); - } - } catch (InvalidKeySpecException e) { - throw new VaultAdminValidationFailedException("Wrong key provided", e); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException(e); - } - } - -} diff --git a/backend/src/main/java/org/cryptomator/hub/filters/VaultAdminTokenIAPNotValidException.java b/backend/src/main/java/org/cryptomator/hub/filters/VaultAdminTokenIAPNotValidException.java deleted file mode 100644 index 5147f0bdc..000000000 --- a/backend/src/main/java/org/cryptomator/hub/filters/VaultAdminTokenIAPNotValidException.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.cryptomator.hub.filters; - -import jakarta.ws.rs.ForbiddenException; - -public class VaultAdminTokenIAPNotValidException extends ForbiddenException { - - public VaultAdminTokenIAPNotValidException(String message) { - super(message); - } - - public VaultAdminTokenIAPNotValidException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/backend/src/main/java/org/cryptomator/hub/filters/VaultAdminValidationFailedException.java b/backend/src/main/java/org/cryptomator/hub/filters/VaultAdminValidationFailedException.java deleted file mode 100644 index 39a6e824d..000000000 --- a/backend/src/main/java/org/cryptomator/hub/filters/VaultAdminValidationFailedException.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.cryptomator.hub.filters; - -import jakarta.ws.rs.BadRequestException; - -public class VaultAdminValidationFailedException extends BadRequestException { - - public VaultAdminValidationFailedException(String message) { - super(message); - } - - public VaultAdminValidationFailedException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/backend/src/main/java/org/cryptomator/hub/filters/VaultRole.java b/backend/src/main/java/org/cryptomator/hub/filters/VaultRole.java new file mode 100644 index 000000000..0dad890db --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/filters/VaultRole.java @@ -0,0 +1,35 @@ +package org.cryptomator.hub.filters; + +import jakarta.ws.rs.NameBinding; +import org.cryptomator.hub.entities.VaultAccess; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to add the {@link VaultRoleFilter} request filter to annotated service. + */ +@NameBinding +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(value = RetentionPolicy.RUNTIME) +public @interface VaultRole { + String DEFAULT_VAULT_ID_PARAM = "vaultId"; + + /** + * @return Roles required to access the annotated resource. Access is granted if _any_ role is present. + */ + VaultAccess.Role[] value() default { VaultAccess.Role.MEMBER }; + + /** + * @return Name of the path parameter containing the {@link org.cryptomator.hub.entities.Vault#id vault id}. + */ + String vaultIdParam() default DEFAULT_VAULT_ID_PARAM; + + /** + * @return How to treat the case when a vault does not exist. + */ + OnMissingVault onMissingVault() default OnMissingVault.FORBIDDEN; + enum OnMissingVault { FORBIDDEN, NOT_FOUND, PASS } +} diff --git a/backend/src/main/java/org/cryptomator/hub/filters/VaultRoleFilter.java b/backend/src/main/java/org/cryptomator/hub/filters/VaultRoleFilter.java new file mode 100644 index 000000000..6e8c3b759 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/filters/VaultRoleFilter.java @@ -0,0 +1,68 @@ +package org.cryptomator.hub.filters; + +import jakarta.inject.Inject; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotAuthorizedException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.ResourceInfo; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.ext.Provider; +import org.cryptomator.hub.entities.EffectiveVaultAccess; +import org.cryptomator.hub.entities.Vault; +import org.cryptomator.hub.entities.VaultAccess; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.Arrays; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Request filter which checks if the user's {@link org.cryptomator.hub.entities.VaultAccess.Role Role} on a {@link org.cryptomator.hub.entities.Vault Vault}. + *

+ * Applied to all methods annotated with {@link VaultRole}. + */ +@Provider +@VaultRole +public class VaultRoleFilter implements ContainerRequestFilter { + + @Inject + JsonWebToken jwt; + + @Context + ResourceInfo resourceInfo; + + @Override + public void filter(ContainerRequestContext requestContext) throws NotFoundException, ForbiddenException, NotAuthorizedException { + var annotation = resourceInfo.getResourceMethod().getAnnotation(VaultRole.class); + var vaultIdStr = requestContext.getUriInfo().getPathParameters().getFirst(annotation.vaultIdParam()); + final UUID vaultId; + try { + vaultId = UUID.fromString(vaultIdStr); + } catch (NullPointerException | IllegalArgumentException e) { + throw new ForbiddenException("@VaultRole not set up correctly (unknown vault id)", e); + } + + var userId = jwt.getSubject(); + if (userId == null) { + throw new NotAuthorizedException("No JWT supplied in request header"); + } + + var forbiddenMsg = "Vault role required: " + Arrays.stream(annotation.value()).map(VaultAccess.Role::name).collect(Collectors.joining(", ")); + if (Vault.findByIdOptional(vaultId).isPresent()) { + // check permissions for existing vault: + var effectiveRoles = EffectiveVaultAccess.listRoles(vaultId, userId); + if (Arrays.stream(annotation.value()).noneMatch(effectiveRoles::contains)) { + throw new ForbiddenException(forbiddenMsg); + } + } else { + // how to treat non-existing vault: + switch (annotation.onMissingVault()) { + case FORBIDDEN -> throw new ForbiddenException(forbiddenMsg); + case NOT_FOUND -> throw new NotFoundException("Vault not found"); + case PASS -> {} + } + } + } +} diff --git a/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java b/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java index cc0a06a35..a837e2776 100644 --- a/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java +++ b/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java @@ -3,6 +3,7 @@ import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.interfaces.Claim; import com.auth0.jwt.interfaces.DecodedJWT; +import io.quarkus.scheduler.Scheduled; import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -11,9 +12,18 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; @ApplicationScoped public class LicenseHolder { @@ -22,28 +32,58 @@ public class LicenseHolder { @ConfigProperty(name = "hub.managed-instance", defaultValue = "false") Boolean managedInstance; + @Inject + @ConfigProperty(name = "hub.initial-id") + Optional initialId; + + @Inject + @ConfigProperty(name = "hub.initial-license") + Optional initialLicense; + + @Inject + LicenseValidator licenseValidator; + + @Inject + RandomMinuteSleeper randomMinuteSleeper; + private static final Logger LOG = Logger.getLogger(LicenseHolder.class); - private final LicenseValidator licenseValidator; private DecodedJWT license; - LicenseHolder(LicenseValidator licenseValidator) { - this.licenseValidator = licenseValidator; - } - /** - * Loads the license from the database, if present + * Loads the license from the database or from init props, if present */ @PostConstruct void init() { var settings = Settings.get(); if (settings.licenseKey != null) { - try { - this.license = licenseValidator.validate(settings.licenseKey, settings.hubId); - } catch (JWTVerificationException e) { - LOG.warn("License in database is invalid. Deleting entry. Please add the license over the REST API again."); - settings.licenseKey = null; - settings.persist(); - } + validateLicense(settings.licenseKey, settings.hubId); + } else if (initialId.isPresent() && initialLicense.isPresent()) { + applyInitialHubIdAndLicense(initialId.get(), initialLicense.get()); + } + } + + @Transactional + void validateLicense(String licenseKey, String hubId) { + try { + this.license = licenseValidator.validate(licenseKey, hubId); + } catch (JWTVerificationException e) { + LOG.warn("Provided license is invalid. Deleting entry. Please add the license over the REST API again."); + var settings = Settings.get(); + settings.licenseKey = null; + settings.persistAndFlush(); + } + } + + @Transactional + void applyInitialHubIdAndLicense(String initialId, String initialLicense) { + try { + this.license = licenseValidator.validate(initialLicense, initialId); + var settings = Settings.get(); + settings.licenseKey = initialLicense; + settings.hubId = initialId; + settings.persistAndFlush(); + } catch (JWTVerificationException e) { + LOG.warn("Provided initial license is invalid."); } } @@ -60,7 +100,47 @@ public void set(String token) throws JWTVerificationException { var settings = Settings.get(); this.license = licenseValidator.validate(token, settings.hubId); settings.licenseKey = token; - settings.persist(); + settings.persistAndFlush(); + } + + /** + * Attempts to refresh the Hub licence every day between 01:00:00 and 02:00:00 AM UTC if claim refreshURL is present. + */ + @Scheduled(cron = "0 0 1 * * ?", timeZone = "UTC", concurrentExecution = Scheduled.ConcurrentExecution.SKIP) + void refreshLicenseScheduler() throws InterruptedException { + if (license != null) { + randomMinuteSleeper.sleep(); // add random sleep between [0,59]min to reduce infrastructure load + var refreshUrl = licenseValidator.refreshUrl(license.getToken()); + if (refreshUrl.isPresent()) { + var client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build(); + refreshLicense(refreshUrl.get(), license.getToken(), client); + } + } + } + + //visible for testing + void refreshLicense(String refreshUrl, String license, HttpClient client) throws InterruptedException { + var parameters = Map.of("token", license); + var body = parameters.entrySet() // + .stream() // + .map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) // + .collect(Collectors.joining("&")); + var request = HttpRequest.newBuilder() // + .uri(URI.create(refreshUrl)) // + .headers("Content-Type", "application/x-www-form-urlencoded") // + .POST(HttpRequest.BodyPublishers.ofString(body)) // + .version(HttpClient.Version.HTTP_1_1) // + .build(); + try { + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 200 && !response.body().isEmpty()) { + set(response.body()); + } else { + LOG.error("Failed to refresh license token with response code: " + response.statusCode()); + } + } catch (IOException | JWTVerificationException e) { + LOG.error("Failed to refresh license token", e); + } } public DecodedJWT get() { diff --git a/backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java b/backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java index 90d6ba213..ff19aba58 100644 --- a/backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java +++ b/backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java @@ -16,6 +16,7 @@ import java.security.spec.X509EncodedKeySpec; import java.time.Instant; import java.util.Objects; +import java.util.Optional; @ApplicationScoped public class LicenseValidator { @@ -33,8 +34,9 @@ public class LicenseValidator { public LicenseValidator() { var algorithm = Algorithm.ECDSA512(decodePublicKey(LICENSE_PUBLIC_KEY), null); - 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(); + var expiresleeway = Instant.now().getEpochSecond(); // this will make sure to accept tokens that expired in the past (beginning from 1970) + // ignoring issued at will make sure to accept tokens that are issued "in the future" e.g. when the hub time is behind the store time + this.verifier = JWT.require(algorithm).acceptExpiresAt(expiresleeway).ignoreIssuedAt().build(); } private static ECPublicKey decodePublicKey(String pemEncodedPublicKey) { @@ -66,4 +68,9 @@ public DecodedJWT validate(String token, String expectedHubId) throws JWTVerific return jwt; } + public Optional refreshUrl(String token) throws JWTVerificationException { + var jwt = verifier.verify(token); + return Optional.ofNullable(jwt.getClaim("refreshUrl").asString()); + } + } diff --git a/backend/src/main/java/org/cryptomator/hub/license/RandomMinuteSleeper.java b/backend/src/main/java/org/cryptomator/hub/license/RandomMinuteSleeper.java new file mode 100644 index 000000000..f5b005e75 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/license/RandomMinuteSleeper.java @@ -0,0 +1,21 @@ +package org.cryptomator.hub.license; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.Random; + +@ApplicationScoped +public class RandomMinuteSleeper { + + private static final long MINUTE_IN_MILLIS = 60 * 1000L; + private final Random rng; + + public RandomMinuteSleeper() { + this.rng = new Random(); + } + + void sleep() throws InterruptedException { + Thread.sleep(rng.nextInt(0, 60) * MINUTE_IN_MILLIS); + } + +} diff --git a/backend/src/main/java/org/cryptomator/hub/validation/OnlyBase64Chars.java b/backend/src/main/java/org/cryptomator/hub/validation/OnlyBase64Chars.java index 237a412f8..8cd68fdfe 100644 --- a/backend/src/main/java/org/cryptomator/hub/validation/OnlyBase64Chars.java +++ b/backend/src/main/java/org/cryptomator/hub/validation/OnlyBase64Chars.java @@ -2,7 +2,6 @@ import jakarta.validation.Constraint; import jakarta.validation.Payload; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import java.lang.annotation.Documented; @@ -13,7 +12,6 @@ import static java.lang.annotation.RetentionPolicy.RUNTIME; @Pattern(regexp = "[+/A-Za-z0-9]+=*") -@NotNull @Target({METHOD, FIELD, ANNOTATION_TYPE, TYPE_USE, PARAMETER}) @Retention(RUNTIME) @Constraint(validatedBy = {}) diff --git a/backend/src/main/java/org/cryptomator/hub/validation/OnlyBase64UrlChars.java b/backend/src/main/java/org/cryptomator/hub/validation/OnlyBase64UrlChars.java deleted file mode 100644 index 0c28c9052..000000000 --- a/backend/src/main/java/org/cryptomator/hub/validation/OnlyBase64UrlChars.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.cryptomator.hub.validation; - -import jakarta.validation.Constraint; -import jakarta.validation.Payload; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; - -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.*; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -@Pattern(regexp = "[-_A-Za-z0-9]+=*") -@NotNull -@Target({METHOD, FIELD, ANNOTATION_TYPE, TYPE_USE, PARAMETER}) -@Retention(RUNTIME) -@Constraint(validatedBy = {}) -@Documented -public @interface OnlyBase64UrlChars { - String message() default "Input is not a valid base64url encoded string"; - - Class[] groups() default {}; - - Class[] payload() default {}; -} diff --git a/backend/src/main/java/org/cryptomator/hub/validation/ValidJWE.java b/backend/src/main/java/org/cryptomator/hub/validation/ValidJWE.java index 97bede999..e902eb0e7 100644 --- a/backend/src/main/java/org/cryptomator/hub/validation/ValidJWE.java +++ b/backend/src/main/java/org/cryptomator/hub/validation/ValidJWE.java @@ -2,7 +2,6 @@ import jakarta.validation.Constraint; import jakarta.validation.Payload; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import java.lang.annotation.Documented; @@ -13,7 +12,6 @@ import static java.lang.annotation.RetentionPolicy.RUNTIME; @Pattern(regexp = "[-_A-Za-z0-9]+=*\\.[-_A-Za-z0-9]*=*\\.[-_A-Za-z0-9]*=*\\.[-_A-Za-z0-9]+=*\\.[-_A-Za-z0-9]*=*") -@NotNull @Target({METHOD, FIELD, ANNOTATION_TYPE, TYPE_USE, PARAMETER}) @Retention(RUNTIME) @Constraint(validatedBy = {}) diff --git a/backend/src/main/java/org/cryptomator/hub/validation/ValidJWS.java b/backend/src/main/java/org/cryptomator/hub/validation/ValidJWS.java index 0a24b53a6..1226eb4b9 100644 --- a/backend/src/main/java/org/cryptomator/hub/validation/ValidJWS.java +++ b/backend/src/main/java/org/cryptomator/hub/validation/ValidJWS.java @@ -2,7 +2,6 @@ import jakarta.validation.Constraint; import jakarta.validation.Payload; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import java.lang.annotation.Documented; @@ -13,7 +12,6 @@ import static java.lang.annotation.RetentionPolicy.RUNTIME; @Pattern(regexp = "[-_A-Za-z0-9]+=*\\.[-_A-Za-z0-9]*=*\\.[-_A-Za-z0-9]*=*") -@NotNull @Target({METHOD, FIELD, ANNOTATION_TYPE, TYPE_USE, PARAMETER}) @Retention(RUNTIME) @Constraint(validatedBy = {}) diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 751bb4d93..15a752d71 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -32,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:22.0.1 +%dev.quarkus.keycloak.devservices.image-name=ghcr.io/cryptomator/keycloak:22.0.5 %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 @@ -77,6 +77,8 @@ quarkus.flyway.locations=classpath:org/cryptomator/hub/flyway # HTTP Security Headers see e.g. https://owasp.org/www-project-secure-headers/#div-bestpractices quarkus.http.header."Content-Security-Policy".value=default-src 'self'; connect-src 'self' api.cryptomator.org; object-src 'none'; child-src 'self'; img-src * data:; frame-ancestors 'none' %dev.quarkus.http.header."Content-Security-Policy".value=default-src 'self'; connect-src 'self' api.cryptomator.org localhost:8180; object-src 'none'; child-src 'self'; img-src * data:; frame-ancestors 'none' +# dev-ui needs very permissive CSP: +# %dev.quarkus.http.header."Content-Security-Policy".value=default-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data:; connect-src 'self' api.cryptomator.org localhost:8180; quarkus.http.header."Referrer-Policy".value=no-referrer quarkus.http.header."Strict-Transport-Security".value=max-age=31536000; includeSubDomains quarkus.http.header."X-Content-Type-Options".value=nosniff diff --git a/backend/src/main/resources/dev-realm.json b/backend/src/main/resources/dev-realm.json index bbfe8e1ab..76d75ff90 100644 --- a/backend/src/main/resources/dev-realm.json +++ b/backend/src/main/resources/dev-realm.json @@ -38,7 +38,8 @@ "composites": { "client": { "realm-management": [ - "view-users" + "view-users", + "view-clients" ] } } @@ -79,6 +80,21 @@ "realmRoles": [ "syncer" ] + }, + { + "username": "cli", + "email": "cli@localhost", + "enabled": true, + "serviceAccountClientId": "cryptomatorhub-cli", + "attributes": { + "picture": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJ5ZXMiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj4KICAgIDxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9ImJsYWNrIi8+CiAgICA8cGF0aCBzdHJva2Utd2lkdGg9IjUiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgZD0iTTMwIDM3LjUgbDE1IDEyLjUgbC0xNSAxMi41ICBtMjAgMCBoMjAiIC8+Cjwvc3ZnPgo=" + }, + "realmRoles": [ + "user" + ], + "clientRoles" : { + "realm-management" : [ "manage-users", "view-users" ] + } } ], "scopeMappings": [ @@ -156,6 +172,16 @@ "attributes": { "pkce.code.challenge.method": "S256" } + }, + { + "clientId": "cryptomatorhub-cli", + "name": "Cryptomator Hub CLI", + "clientAuthenticatorType": "client-secret", + "secret": "top-secret", + "standardFlowEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "enabled": true } ], "browserSecurityHeaders": { diff --git a/backend/src/main/resources/org/cryptomator/hub/flyway/B14__Hub_1.3.0.sql b/backend/src/main/resources/org/cryptomator/hub/flyway/B14__Hub_1.3.0.sql new file mode 100644 index 000000000..3fed56430 --- /dev/null +++ b/backend/src/main/resources/org/cryptomator/hub/flyway/B14__Hub_1.3.0.sql @@ -0,0 +1,262 @@ +-- noinspection SqlNoDataSourceInspectionForFile + +CREATE TABLE "settings" +( + "id" INT4 NOT NULL, + "hub_id" VARCHAR(255) NOT NULL, + "license_key" VARCHAR, + CONSTRAINT "SETTINGS_PK" PRIMARY KEY ("id") +); + +INSERT INTO "settings" ("id", "hub_id") VALUES (0, gen_random_uuid()); + +CREATE TABLE "authority" +( + "id" VARCHAR(255) COLLATE "C" NOT NULL, + "type" VARCHAR(5) NOT NULL, + "name" VARCHAR NOT NULL, + CONSTRAINT "AUTHORITY_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUTHORITY_CHK_TYPE" CHECK ("type" = 'USER' OR "type" = 'GROUP') +); + +CREATE TABLE "group_membership" +( + "group_id" VARCHAR(255) COLLATE "C" NOT NULL, + "member_id" VARCHAR(255) COLLATE "C" NOT NULL, + CONSTRAINT "GROUP_MEMBERSHIP_PK" PRIMARY KEY ("group_id", "member_id"), + CONSTRAINT "GROUP_MEMBERSHIP_FK_GROUP" FOREIGN KEY ("group_id") REFERENCES "authority" ("id") ON DELETE CASCADE, + CONSTRAINT "GROUP_MEMBERSHIP_FK_MEMBER" FOREIGN KEY ("member_id") REFERENCES "authority" ("id") ON DELETE CASCADE, + CONSTRAINT "GROUP_MEMBERSHIP_CHK_NOTSAME" CHECK ("group_id" <> "member_id") +); + +-- @formatter:off +CREATE VIEW "effective_group_membership" ("group_id", "member_id", "path") AS +WITH RECURSIVE "members" ("root", "member_id", "depth", "path") AS ( + SELECT "group_id", "member_id", 0, '/' || "group_id" || '/' || "member_id" + FROM "group_membership" + UNION + SELECT "parent"."root", "child"."member_id", "parent"."depth" + 1, "parent"."path" || '/' || "child"."member_id" + FROM "group_membership" "child" + INNER JOIN "members" "parent" ON "child"."group_id" = "parent"."member_id" + WHERE "parent"."depth" < 10 +) SELECT "root", "member_id", "path" FROM "members"; +-- @formatter:on + +CREATE TABLE "user_details" +( + "id" VARCHAR(255) COLLATE "C" NOT NULL, + "picture_url" VARCHAR, + "email" VARCHAR, + "publickey" VARCHAR, -- base64-encoded SPKI DER (RFC 5280, 4.1.2.7) + "privatekey" VARCHAR, -- private key, encrypted using setup code (JWE PBES2) + "setupcode" VARCHAR, -- setup code, encrypted using user's public key (JWE ECDH-ES) + CONSTRAINT "USER_DETAIL_PK" PRIMARY KEY ("id"), + CONSTRAINT "USER_DETAIL_FK_USER" FOREIGN KEY ("id") REFERENCES "authority" ("id") ON DELETE CASCADE +); + +CREATE TABLE "group_details" +( + "id" VARCHAR(255) COLLATE "C" NOT NULL, + CONSTRAINT "GROUP_DETAIL_PK" PRIMARY KEY ("id"), + CONSTRAINT "GROUP_DETAIL_FK_GROUP" FOREIGN KEY ("id") REFERENCES "authority" ("id") ON DELETE CASCADE +); + +CREATE TABLE "vault" +( + "id" UUID NOT NULL, + "name" VARCHAR NOT NULL, + "description" VARCHAR, + "creation_time" TIMESTAMP WITH TIME ZONE NOT NULL, + "archived" BOOLEAN NOT NULL DEFAULT false, + "salt" VARCHAR(255), -- deprecated ("vault admin password") + "iterations" INTEGER, -- deprecated ("vault admin password") + "masterkey" VARCHAR(255), -- deprecated ("vault admin password") + "auth_pubkey" VARCHAR, -- deprecated ("vault admin password") + "auth_prvkey" VARCHAR, -- deprecated ("vault admin password") + CONSTRAINT "VAULT_PK" PRIMARY KEY ("id") +); + +CREATE TABLE "vault_access" +( + "vault_id" UUID NOT NULL, + "authority_id" VARCHAR(255) COLLATE "C" NOT NULL, + "role" VARCHAR(50) NOT NULL DEFAULT 'MEMBER', + CONSTRAINT "VAULT_ACCESS_PK" PRIMARY KEY ("vault_id", "authority_id"), + CONSTRAINT "VAULT_ACCESS_FK_VAULT" FOREIGN KEY ("vault_id") REFERENCES "vault" ("id") ON DELETE CASCADE, + CONSTRAINT "VAULT_ACCESS_FK_AUTHORITY" FOREIGN KEY ("authority_id") REFERENCES "authority" ("id") ON DELETE CASCADE +); + +-- @formatter:off +CREATE VIEW "effective_vault_access" ("vault_id", "authority_id", "role") AS + SELECT "va"."vault_id", "va"."authority_id", "va"."role" FROM "vault_access" "va" + UNION + SELECT "va"."vault_id", "gm"."member_id", "va"."role" FROM "vault_access" "va" + INNER JOIN "effective_group_membership" "gm" ON "va"."authority_id" = "gm"."group_id"; +-- @formatter:on + +CREATE TABLE "device" +( + "id" VARCHAR(255) NOT NULL, + "owner_id" VARCHAR(255) NOT NULL, + "name" VARCHAR NOT NULL, + "type" VARCHAR(50) NOT NULL DEFAULT 'DESKTOP', + "publickey" VARCHAR NOT NULL, -- base64-encoded SPKI DER (RFC 5280, 4.1.2.7) + "user_privatekey" VARCHAR NOT NULL UNIQUE, -- private key, encrypted using device's public key (JWE ECDH-ES) + "creation_time" TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT "DEVICE_PK" PRIMARY KEY ("id"), + CONSTRAINT "DEVICE_FK_USER" FOREIGN KEY ("owner_id") REFERENCES "user_details" ("id") ON DELETE CASCADE +); + +CREATE TABLE "access_token" +( + "user_id" VARCHAR(255) COLLATE "C" NOT NULL, + "vault_id" UUID NOT NULL, + "vault_masterkey" VARCHAR NOT NULL UNIQUE, -- private key, encrypted using user's public key (JWE ECDH-ES) + CONSTRAINT "ACCESS_PK" PRIMARY KEY ("user_id", "vault_id"), + CONSTRAINT "ACCESS_FK_USER" FOREIGN KEY ("user_id") REFERENCES "user_details" ("id") ON DELETE CASCADE, + CONSTRAINT "ACCESS_FK_VAULT" FOREIGN KEY ("vault_id") REFERENCES "vault" ("id") ON DELETE CASCADE +); + +-- ------------- -- +-- LEGACY TABLES -- +-- ------------- -- +CREATE TABLE "device_legacy" +( + "id" VARCHAR(255) COLLATE "C" NOT NULL, + "owner_id" VARCHAR(255) COLLATE "C" NOT NULL, + "name" VARCHAR NOT NULL, + "type" VARCHAR(50) NOT NULL DEFAULT 'DESKTOP', + "publickey" VARCHAR NOT NULL, + "creation_time" TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT "DEVICE_LEGACY_PK" PRIMARY KEY ("id"), + CONSTRAINT "DEVICE_LEGACY_FK_USER" FOREIGN KEY ("owner_id") REFERENCES "authority" ("id") ON DELETE CASCADE, + CONSTRAINT "DEVICE_LEGACY_UNIQUE_NAME_PER_OWNER" UNIQUE ("owner_id", "name") +); +COMMENT ON COLUMN "device_legacy"."publickey" IS 'Note: This contains base64url-encoded data for historic reasons.'; + +CREATE TABLE "access_token_legacy" +( + "device_id" VARCHAR(255) COLLATE "C" NOT NULL, + "vault_id" UUID NOT NULL, + "jwe" VARCHAR NOT NULL UNIQUE, + CONSTRAINT "ACCESS_LEGACY_PK" PRIMARY KEY ("device_id", "vault_id"), + CONSTRAINT "ACCESS_LEGACY_FK_DEVICE" FOREIGN KEY ("device_id") REFERENCES "device_legacy" ("id") ON DELETE CASCADE, + CONSTRAINT "ACCESS_LEGACY_FK_VAULT" FOREIGN KEY ("vault_id") REFERENCES "vault" ("id") ON DELETE CASCADE +); + +-- --------- -- +-- AUDIT LOG -- +-- --------- -- +CREATE SEQUENCE audit_event_id_seq AS BIGINT; +CREATE TABLE "audit_event" +( + "id" BIGINT NOT NULL DEFAULT nextval('audit_event_id_seq'), + "type" VARCHAR(50) NOT NULL, + "timestamp" TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT "AUDIT_EVENT_PK" PRIMARY KEY ("id") +); +ALTER SEQUENCE audit_event_id_seq OWNED BY audit_event.id; + +CREATE TABLE "audit_event_vault_create" +( + "id" BIGINT NOT NULL, + "created_by" VARCHAR(255) COLLATE "C" NOT NULL, + "vault_id" UUID NOT NULL, + "vault_name" VARCHAR NOT NULL, + "vault_description" VARCHAR, + CONSTRAINT "AUDIT_EVENT_VAULT_CREATE_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUDIT_EVENT_VAULT_CREATE_FK_AUDIT_EVENT" FOREIGN KEY ("id") REFERENCES "audit_event" ("id") ON DELETE CASCADE +); + +CREATE TABLE "audit_event_vault_key_retrieve" +( + "id" BIGINT NOT NULL, + "retrieved_by" VARCHAR(255) COLLATE "C" NOT NULL, + "vault_id" UUID NOT NULL, + "result" VARCHAR(50) NOT NULL, + CONSTRAINT "AUDIT_EVENT_VAULT_UNLOCK_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUDIT_EVENT_VAULT_UNLOCK_FK_AUDIT_EVENT" FOREIGN KEY ("id") REFERENCES "audit_event" ("id") ON DELETE CASCADE +); + +CREATE TABLE "audit_event_vault_member_add" +( + "id" BIGINT NOT NULL, + "added_by" VARCHAR(255) COLLATE "C" NOT NULL, + "vault_id" UUID NOT NULL, + "authority_id" VARCHAR(255) COLLATE "C" NOT NULL, + "role" VARCHAR(50) NOT NULL DEFAULT 'MEMBER', + CONSTRAINT "AUDIT_EVENT_VAULT_MEMBER_ADD_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUDIT_EVENT_VAULT_MEMBER_ADD_FK_AUDIT_EVENT" FOREIGN KEY ("id") REFERENCES "audit_event" ("id") ON DELETE CASCADE +); + +CREATE TABLE "audit_event_vault_member_remove" +( + "id" BIGINT NOT NULL, + "removed_by" VARCHAR(255) COLLATE "C" NOT NULL, + "vault_id" UUID NOT NULL, + "authority_id" VARCHAR(255) COLLATE "C" NOT NULL, + CONSTRAINT "AUDIT_EVENT_VAULT_MEMBER_REMOVE_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUDIT_EVENT_VAULT_MEMBER_REMOVE_FK_AUDIT_EVENT" FOREIGN KEY ("id") REFERENCES "audit_event" ("id") ON DELETE CASCADE +); + +CREATE TABLE "audit_event_vault_member_update" +( + "id" BIGINT NOT NULL, + "updated_by" VARCHAR(255) COLLATE "C" NOT NULL, + "vault_id" UUID NOT NULL, + "authority_id" VARCHAR(255) COLLATE "C" NOT NULL, + "role" VARCHAR(50) NOT NULL, + CONSTRAINT "AUDIT_EVENT_VAULT_MEMBER_UPDATE_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUDIT_EVENT_VAULT_MEMBER_UPDATE_FK_AUDIT_EVENT" FOREIGN KEY ("id") REFERENCES "audit_event" ("id") ON DELETE CASCADE +); + +CREATE TABLE "audit_event_vault_ownership_claim" +( + "id" BIGINT NOT NULL, + "claimed_by" VARCHAR(255) COLLATE "C" NOT NULL, + "vault_id" UUID NOT NULL, + CONSTRAINT "AUDIT_EVENT_VAULT_OWNERSHIP_CLAIM_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUDIT_EVENT_VAULT_OWNERSGIP_CLAIM_FK_AUDIT_EVENT" FOREIGN KEY ("id") REFERENCES "audit_event" ("id") ON DELETE CASCADE +); + +CREATE TABLE "audit_event_device_register" +( + "id" BIGINT NOT NULL, + "registered_by" VARCHAR(255) COLLATE "C" NOT NULL, + "device_id" VARCHAR(64) COLLATE "C" NOT NULL, + "device_name" VARCHAR NOT NULL, + "device_type" VARCHAR(50) NOT NULL, + CONSTRAINT "AUDIT_EVENT_DEVICE_REGISTER_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUDIT_EVENT_DEVICE_REGISTER_FK_AUDIT_EVENT" FOREIGN KEY ("id") REFERENCES "audit_event" ("id") ON DELETE CASCADE +); + +CREATE TABLE "audit_event_device_remove" +( + "id" BIGINT NOT NULL, + "removed_by" VARCHAR(255) COLLATE "C" NOT NULL, + "device_id" VARCHAR(64) COLLATE "C" NOT NULL, + CONSTRAINT "AUDIT_EVENT_DEVICE_REMOVE_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUDIT_EVENT_DEVICE_REMOVE_FK_AUDIT_EVENT" FOREIGN KEY ("id") REFERENCES "audit_event" ("id") ON DELETE CASCADE +); + +CREATE TABLE "audit_event_vault_update" +( + "id" BIGINT NOT NULL, + "updated_by" VARCHAR(255) COLLATE "C" NOT NULL, + "vault_id" UUID NOT NULL, + "vault_name" VARCHAR NOT NULL, + "vault_description" VARCHAR, + "vault_archived" BOOLEAN NOT NULL, + CONSTRAINT "AUDIT_EVENT_VAULT_UPDATE_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUDIT_EVENT_VAULT_UPDATE_FK_AUDIT_EVENT" FOREIGN KEY ("id") REFERENCES "audit_event" ("id") ON DELETE CASCADE +); + +CREATE TABLE "audit_event_vault_access_grant" +( + "id" BIGINT NOT NULL, + "granted_by" VARCHAR(255) COLLATE "C" NOT NULL, + "vault_id" UUID NOT NULL, + "authority_id" VARCHAR(255) COLLATE "C" NOT NULL, + CONSTRAINT "AUDIT_EVENT_VAULT_ACCESS_GRANT_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUDIT_EVENT_VAULT_ACCESS_GRANT_FK_AUDIT_EVENT" FOREIGN KEY ("id") REFERENCES "audit_event" ("id") ON DELETE CASCADE +); \ No newline at end of file diff --git a/backend/src/main/resources/org/cryptomator/hub/flyway/ERM.png b/backend/src/main/resources/org/cryptomator/hub/flyway/ERM.png index 1ef70a42b..c19e352b3 100644 Binary files a/backend/src/main/resources/org/cryptomator/hub/flyway/ERM.png and b/backend/src/main/resources/org/cryptomator/hub/flyway/ERM.png differ diff --git a/backend/src/main/resources/org/cryptomator/hub/flyway/V14__User_Keys.sql b/backend/src/main/resources/org/cryptomator/hub/flyway/V14__User_Keys.sql new file mode 100644 index 000000000..99d1584bb --- /dev/null +++ b/backend/src/main/resources/org/cryptomator/hub/flyway/V14__User_Keys.sql @@ -0,0 +1,94 @@ +-- remove varchar length restrictions, see https://wiki.postgresql.org/wiki/Don%27t_Do_This#Don.27t_use_varchar.28n.29_by_default +ALTER TABLE "settings" ALTER COLUMN "license_key" SET DATA TYPE VARCHAR; +ALTER TABLE "authority" ALTER COLUMN "name" SET DATA TYPE VARCHAR; +ALTER TABLE "user_details" ALTER COLUMN "picture_url" SET DATA TYPE VARCHAR; +ALTER TABLE "user_details" ALTER COLUMN "email" SET DATA TYPE VARCHAR; +ALTER TABLE "vault" ALTER COLUMN "name" SET DATA TYPE VARCHAR; +ALTER TABLE "vault" ALTER COLUMN "description" SET DATA TYPE TEXT; +ALTER TABLE "vault" ALTER COLUMN "auth_pubkey" SET DATA TYPE VARCHAR; +ALTER TABLE "vault" ALTER COLUMN "auth_prvkey" SET DATA TYPE VARCHAR; +ALTER TABLE "device" ALTER COLUMN "name" SET DATA TYPE VARCHAR; +ALTER TABLE "device" ALTER COLUMN "publickey" SET DATA TYPE VARCHAR; +ALTER TABLE "access_token" ALTER COLUMN "jwe" SET DATA TYPE VARCHAR; +ALTER TABLE "audit_event_vault_create" ALTER COLUMN "vault_name" SET DATA TYPE VARCHAR; +ALTER TABLE "audit_event_vault_create" ALTER COLUMN "vault_description" SET DATA TYPE VARCHAR; +ALTER TABLE "audit_event_vault_update" ALTER COLUMN "vault_name" SET DATA TYPE VARCHAR; +ALTER TABLE "audit_event_vault_update" ALTER COLUMN "vault_description" SET DATA TYPE VARCHAR; +ALTER TABLE "audit_event_device_register" ALTER COLUMN "device_name" SET DATA TYPE VARCHAR; + +-- users will generate a new key pair during first login in the browser: +ALTER TABLE "user_details" ADD "publickey" VARCHAR; -- base64-encoded SPKI DER (RFC 5280, 4.1.2.7) +ALTER TABLE "user_details" ADD "privatekey" VARCHAR; -- private key, encrypted using setup code (JWE PBES2) +ALTER TABLE "user_details" ADD "setupcode" VARCHAR; -- setup code, encrypted using user's public key (JWE ECDH-ES) + +-- keep existing device-based access tokens for continuous unlock from old clients. +ALTER TABLE "access_token" RENAME TO "access_token_legacy"; +ALTER TABLE "access_token_legacy" RENAME CONSTRAINT "ACCESS_PK" TO "ACCESS_LEGACY_PK"; +ALTER TABLE "access_token_legacy" RENAME CONSTRAINT "ACCESS_FK_DEVICE" TO "ACCESS_LEGACY_FK_DEVICE"; +ALTER TABLE "access_token_legacy" RENAME CONSTRAINT "ACCESS_FK_VAULT" TO "ACCESS_LEGACY_FK_VAULT"; +ALTER TABLE "device" RENAME TO "device_legacy"; +ALTER TABLE "device_legacy" RENAME CONSTRAINT "DEVICE_PK" TO "DEVICE_LEGACY_PK"; +ALTER TABLE "device_legacy" RENAME CONSTRAINT "DEVICE_FK_USER" TO "DEVICE_LEGACY_FK_USER"; +ALTER TABLE "device_legacy" RENAME CONSTRAINT "DEVICE_UNIQUE_NAME_PER_OWNER" TO "DEVICE_LEGACY_UNIQUE_NAME_PER_OWNER"; +COMMENT ON COLUMN "device_legacy"."publickey" IS 'Note: This contains base64url-encoded data for historic reasons.'; + +-- new device table with non-null user_privatekey: +CREATE TABLE "device" +( + "id" VARCHAR(255) NOT NULL, + "owner_id" VARCHAR(255) NOT NULL, + "name" VARCHAR NOT NULL, + "type" VARCHAR(50) NOT NULL DEFAULT 'DESKTOP', + "publickey" VARCHAR NOT NULL, + "user_privatekey" VARCHAR NOT NULL UNIQUE, -- private key, encrypted using device's public key (JWE ECDH-ES) + "creation_time" TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT "DEVICE_PK" PRIMARY KEY ("id"), + CONSTRAINT "DEVICE_FK_USER" FOREIGN KEY ("owner_id") REFERENCES "user_details" ("id") ON DELETE CASCADE +); + +-- new access tokens will be issued for users (not devices): +CREATE TABLE "access_token" +( + "user_id" VARCHAR(255) COLLATE "C" NOT NULL, + "vault_id" UUID NOT NULL, + "vault_masterkey" VARCHAR NOT NULL UNIQUE, -- private key, encrypted using user's public key (JWE ECDH-ES) + CONSTRAINT "ACCESS_PK" PRIMARY KEY ("user_id", "vault_id"), + CONSTRAINT "ACCESS_FK_USER" FOREIGN KEY ("user_id") REFERENCES "user_details" ("id") ON DELETE CASCADE, + CONSTRAINT "ACCESS_FK_VAULT" FOREIGN KEY ("vault_id") REFERENCES "vault" ("id") ON DELETE CASCADE +); + +ALTER TABLE "vault_access" ADD "role" VARCHAR(50) NOT NULL DEFAULT 'MEMBER'; +ALTER TABLE "audit_event_vault_member_add" ADD "role" VARCHAR(50) NOT NULL DEFAULT 'MEMBER'; +CREATE TABLE "audit_event_vault_member_update" +( + "id" BIGINT NOT NULL, + "updated_by" VARCHAR(255) COLLATE "C" NOT NULL, + "vault_id" UUID NOT NULL, + "authority_id" VARCHAR(255) COLLATE "C" NOT NULL, + "role" VARCHAR(50) NOT NULL, + CONSTRAINT "AUDIT_EVENT_VAULT_MEMBER_UPDATE_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUDIT_EVENT_VAULT_MEMBER_UPDATE_FK_AUDIT_EVENT" FOREIGN KEY ("id") REFERENCES "audit_event" ("id") ON DELETE CASCADE +); +CREATE TABLE "audit_event_vault_ownership_claim" +( + "id" BIGINT NOT NULL, + "claimed_by" VARCHAR(255) COLLATE "C" NOT NULL, + "vault_id" UUID NOT NULL, + CONSTRAINT "AUDIT_EVENT_VAULT_OWNERSHIP_CLAIM_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUDIT_EVENT_VAULT_OWNERSGIP_CLAIM_FK_AUDIT_EVENT" FOREIGN KEY ("id") REFERENCES "audit_event" ("id") ON DELETE CASCADE +); + +-- @formatter:off +CREATE OR REPLACE VIEW "effective_vault_access" ("vault_id", "authority_id", "role") AS + SELECT "va"."vault_id", "va"."authority_id", "va"."role" FROM "vault_access" "va" + UNION + SELECT "va"."vault_id", "gm"."member_id", "va"."role" FROM "vault_access" "va" + INNER JOIN "effective_group_membership" "gm" ON "va"."authority_id" = "gm"."group_id"; +-- @formatter:on + +-- deprecate vault admin password +ALTER TABLE "vault" ALTER COLUMN "salt" DROP NOT NULL; +ALTER TABLE "vault" ALTER COLUMN "iterations" DROP NOT NULL; +ALTER TABLE "vault" ALTER COLUMN "masterkey" DROP NOT NULL; +ALTER TABLE "vault" ALTER COLUMN "auth_pubkey" DROP NOT NULL; +ALTER TABLE "vault" ALTER COLUMN "auth_prvkey" DROP NOT NULL; diff --git a/backend/src/test/java/org/cryptomator/hub/KeycloakRemoteUserProviderTest.java b/backend/src/test/java/org/cryptomator/hub/KeycloakRemoteUserProviderTest.java index 2d12f18c1..f8b85c530 100644 --- a/backend/src/test/java/org/cryptomator/hub/KeycloakRemoteUserProviderTest.java +++ b/backend/src/test/java/org/cryptomator/hub/KeycloakRemoteUserProviderTest.java @@ -7,14 +7,18 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.admin.client.resource.ClientsResource; import org.keycloak.admin.client.resource.GroupResource; import org.keycloak.admin.client.resource.GroupsResource; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.mockito.Mockito; +import java.util.Comparator; import java.util.List; import java.util.Map; @@ -26,12 +30,25 @@ class KeycloakRemoteUserProviderTest { private KeycloakRemoteUserProvider keycloakRemoteUserProvider; private UserRepresentation user1 = Mockito.mock(UserRepresentation.class); private UserRepresentation user2 = Mockito.mock(UserRepresentation.class); + private UserRepresentation syncer = Mockito.mock(UserRepresentation.class); + private UserRepresentation hubCliUser = Mockito.mock(UserRepresentation.class); + + private ClientsResource hubCliClientsResource = Mockito.mock(ClientsResource.class); + + private ClientRepresentation hubCliClientRepresentation = Mockito.mock(ClientRepresentation.class); + + private ClientResource hubCliClientResource = Mockito.mock(ClientResource.class); + + @BeforeEach void setUp() { var synerConfig = Mockito.mock(SyncerConfig.class); + Mockito.when(realm.clients()).thenReturn(hubCliClientsResource); + Mockito.when(realm.clients().findByClientId("cryptomatorhub-cli")).thenReturn(List.of()); + Mockito.when(realm.users()).thenReturn(usersResource); Mockito.when(user1.getId()).thenReturn("id3000"); @@ -53,7 +70,7 @@ void setUp() { @Test @DisplayName("test user listing excludes syncer and returns two users") - public void testListUser() { + void testListUser() { Mockito.when(usersResource.list(0, KeycloakRemoteUserProvider.MAX_COUNT_PER_REQUEST)).thenReturn(List.of(user1, user2, syncer)); var result = keycloakRemoteUserProvider.users(realm); @@ -74,9 +91,51 @@ public void testListUser() { Assertions.assertNull(resultUser2.pictureUrl); } + @Test + @DisplayName("test user listing excludes syncer, includes Hub CLI user and returns two users") + void testListUserIncludingHubCliUser() { + Mockito.when(usersResource.list(0, KeycloakRemoteUserProvider.MAX_COUNT_PER_REQUEST)).thenReturn(List.of(user1, user2, syncer)); + + Mockito.when(realm.clients()).thenReturn(hubCliClientsResource); + + List clientRepresentations = Mockito.mock(List.class); + Mockito.when(realm.clients().findByClientId("cryptomatorhub-cli")).thenReturn(clientRepresentations); + Mockito.when(clientRepresentations.get(0)).thenReturn(hubCliClientRepresentation); + + Mockito.when(hubCliClientRepresentation.getId()).thenReturn("cryptomatorHubCliClientId"); + + Mockito.when(realm.clients().get(Mockito.anyString())).thenReturn(hubCliClientResource); + + Mockito.when(hubCliUser.getId()).thenReturn("cryptomatorHubCliUserId"); + Mockito.when(hubCliUser.getUsername()).thenReturn("cryptomatorHubCliUserUsername"); + Mockito.when(hubCliClientResource.getServiceAccountUser()).thenReturn(hubCliUser); + + var result = keycloakRemoteUserProvider.users(realm); + + Assertions.assertEquals(3, result.size()); + + var resultUser1 = result.get(0); + var resultUser2 = result.get(1); + var resultUser3 = result.get(2); + + Assertions.assertEquals("id3000", resultUser1.id); + Assertions.assertEquals("username3000", resultUser1.name); + Assertions.assertEquals("email3000", resultUser1.email); + Assertions.assertEquals("picture3000", resultUser1.pictureUrl); + + Assertions.assertEquals("id3001", resultUser2.id); + Assertions.assertEquals("username3001", resultUser2.name); + Assertions.assertEquals("email3001", resultUser2.email); + Assertions.assertNull(resultUser2.pictureUrl); + + Assertions.assertEquals("cryptomatorHubCliUserId", resultUser3.id); + Assertions.assertEquals("cryptomatorHubCliUserUsername", resultUser3.name); + } + + @Nested @DisplayName("Test groups") - public class Groups { + class Groups { private GroupsResource groupsResource = Mockito.mock(GroupsResource.class); private GroupRepresentation group1 = Mockito.mock(GroupRepresentation.class); @@ -121,9 +180,9 @@ public void testListGroups() { Assertions.assertEquals("grpName3001", resultGroup2.name); Assertions.assertEquals(2, resultGroup2.members.size()); - var membersGroup2 = resultGroup2.members.stream().toList(); - var member1Group2 = (User) membersGroup2.get(1); - var member2Group2 = (User) membersGroup2.get(0); + var membersGroup2 = resultGroup2.members.stream().sorted(Comparator.comparing(a -> a.id)).toList(); + var member1Group2 = (User) membersGroup2.get(0); + var member2Group2 = (User) membersGroup2.get(1); Assertions.assertEquals("id3000", member1Group2.id); Assertions.assertEquals("username3000", member1Group2.name); diff --git a/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceTest.java index a783b1de7..c128f55ef 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceTest.java @@ -30,7 +30,7 @@ public void testGetAuditLogEntries() { .param("paginationId", 9999L) .when().get("/auditlog") .then().statusCode(200) - .body("id", Matchers.containsInRelativeOrder(comparesEqualTo(4242), comparesEqualTo(3000), comparesEqualTo(2003), comparesEqualTo(2002), comparesEqualTo(2001), comparesEqualTo(2000), comparesEqualTo(1111), comparesEqualTo(201), comparesEqualTo(200), comparesEqualTo(102), comparesEqualTo(101), comparesEqualTo(100), comparesEqualTo(31), comparesEqualTo(30), comparesEqualTo(23), comparesEqualTo(22), comparesEqualTo(21), comparesEqualTo(20), comparesEqualTo(12), comparesEqualTo(11))); + .body("id", Matchers.containsInRelativeOrder(comparesEqualTo(4242), comparesEqualTo(3000), comparesEqualTo(2003), comparesEqualTo(2002), comparesEqualTo(2001), comparesEqualTo(2000), comparesEqualTo(1111), comparesEqualTo(201), comparesEqualTo(200), comparesEqualTo(102), comparesEqualTo(101), comparesEqualTo(100), comparesEqualTo(31), comparesEqualTo(30), comparesEqualTo(25), comparesEqualTo(24), comparesEqualTo(23), comparesEqualTo(22), comparesEqualTo(21), comparesEqualTo(20))); } @Test diff --git a/backend/src/test/java/org/cryptomator/hub/api/AuthorityResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/AuthorityResourceTest.java index a66e75f71..129a3bf58 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/AuthorityResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/AuthorityResourceTest.java @@ -23,9 +23,6 @@ @DisplayName("Resource /authorities") public class AuthorityResourceTest { - @Inject - AgroalDataSource dataSource; - @BeforeAll public static void beforeAll() { RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); @@ -121,17 +118,5 @@ public void testGetSome() { .then().statusCode(200) .body("id", containsInAnyOrder("user1", "group2")); } - - @Test - @DisplayName("GET /authorities?ids=user1&ids=group2 as user returns 403") - @TestSecurity(user = "User Name 1", roles = {"user"}) - @OidcSecurity(claims = { - @Claim(key = "sub", value = "user1") - }) - public void testGetSomeAsUser() { - given().param("ids", "user1", "group2") - .when().get("/authorities") - .then().statusCode(403); - } } } \ No newline at end of file diff --git a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceTest.java b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceTest.java index f978cad0b..c4a85708b 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceTest.java @@ -1,6 +1,7 @@ package org.cryptomator.hub.api; import io.agroal.api.AgroalDataSource; +import io.quarkus.arc.Arc; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTestProfile; import io.quarkus.test.junit.TestProfile; @@ -9,6 +10,7 @@ import io.quarkus.test.security.oidc.OidcSecurity; import io.restassured.RestAssured; import jakarta.inject.Inject; +import org.cryptomator.hub.license.LicenseHolder; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -35,6 +37,7 @@ public class BillingResourceManagedInstanceTest { @BeforeAll public static void beforeAll() { RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + Arc.container().instance(LicenseHolder.class).destroy(); } public static class ManagedInstanceTestProfile implements QuarkusTestProfile { @@ -47,7 +50,7 @@ public Map getConfigOverrides() { @Test @DisplayName("GET /billing returns 401 with empty license managed instance") public void testGetEmptyManagedInstance() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { s.execute(""" UPDATE "settings" SET "hub_id" = '42', "license_key" = null diff --git a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceTest.java index fe56b6b92..dbea7086b 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceTest.java @@ -3,7 +3,7 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.exceptions.JWTVerificationException; import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.junit.mockito.InjectMock; +import io.quarkus.test.InjectMock; import io.quarkus.test.security.TestSecurity; import io.quarkus.test.security.oidc.Claim; import io.quarkus.test.security.oidc.OidcSecurity; diff --git a/backend/src/test/java/org/cryptomator/hub/api/DeviceResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/DeviceResourceTest.java index ddb3bbaba..afd40c3de 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/DeviceResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/DeviceResourceTest.java @@ -9,17 +9,21 @@ import io.restassured.http.ContentType; import jakarta.inject.Inject; import org.cryptomator.hub.entities.Device; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; import java.sql.SQLException; import java.time.Instant; -import java.util.Set; import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.when; +import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.hasSize; @@ -41,9 +45,11 @@ public static void beforeAll() { @OidcSecurity(claims = { @Claim(key = "sub", value = "user1") }) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class AsAuthorzedUser1 { @Test + @Order(1) @DisplayName("PUT /devices/device1 without DTO returns 400") public void testCreateNoDeviceDto() { given().contentType(ContentType.JSON).body("") @@ -52,58 +58,131 @@ public void testCreateNoDeviceDto() { } @Test - @DisplayName("PUT /devices/ with DTO returns 400") + @Order(1) + @DisplayName("PUT /devices/ with DTO returns 400") public void testCreateNoDeviceId() { - var deviceDto = new DeviceResource.DeviceDto("device1", "Computer 1", Device.Type.DESKTOP, "publickey1", "", Set.of(), 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")); given().contentType(ContentType.JSON).body(deviceDto) - .when().put("/devices/{deviceId}", "\u0020") //a whitespace + .when().put("/devices/{deviceId}", " ") //a whitespace .then().statusCode(400); } @Test - @DisplayName("PUT /devices/device1 returns 409") - public void testCreate1() { - var deviceDto = new DeviceResource.DeviceDto("device1", "Computer 1", Device.Type.DESKTOP, "publickey1", "owner1", Set.of(), Instant.parse("2020-02-20T20:20:20Z")); + @Order(1) + @DisplayName("GET /devices/device1 returns 200") + public void testGet1() { + given().when().get("/devices/{deviceId}", "device1") + .then().statusCode(200) + .body("id", is("device1")) + .body("name", is("Computer 1")) + .body("userPrivateKey", is("jwe.jwe.jwe.user1.device1")); + } + + @Test + @Order(1) + @DisplayName("GET /devices/device2 returns 404 (owned by other user)") + public void testGet2() { + given().when().get("/devices/{deviceId}", "device2") + .then().statusCode(404); + } + + @Test + @Order(1) + @DisplayName("GET /devices/noSuchDevice returns 404 (no such device)") + public void testGetNonExistingDeviceToken() { + when().get("/devices/{deviceId}", "noSuchDevice") + .then().statusCode(404); + } + + @Test + @Order(2) + @DisplayName("PUT /devices/device999 returns 201 (creating new device)") + public void testCreate999() throws SQLException { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + s.execute(""" + INSERT INTO "device_legacy" ("id", "owner_id", "name", "type", "publickey", "creation_time") + VALUES + ('device999', 'user1', 'Computer 999', 'DESKTOP', 'publickey999', '2020-02-20 20:20:20') + """); + } + + 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")); given().contentType(ContentType.JSON).body(deviceDto) - .when().put("/devices/{deviceId}", "device1") - .then().statusCode(409); + .when().put("/devices/{deviceId}", "device999") + .then().statusCode(201); + + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + var rs = s.executeQuery(""" + SELECT * FROM "device_legacy" WHERE "id" = 'device999'; + """); + Assertions.assertFalse(rs.next()); + } } @Test - @DisplayName("PUT /devices/deviceX returns 409 due to non-unique name") + @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", "owner1", Set.of(), 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")); given().contentType(ContentType.JSON).body(deviceDto) .when().put("/devices/{deviceId}", "deviceX") + .then().statusCode(201); + } + + @Test + @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")); + + given().contentType(ContentType.JSON).body(deviceDto) + .when().put("/devices/{deviceId}", "deviceY") .then().statusCode(409); } @Test - @DisplayName("PUT /devices/device999 returns 201") - public void testCreate2() throws SQLException { - var deviceDto = new DeviceResource.DeviceDto("device999", "Computer 999", Device.Type.DESKTOP, "publickey999", "owner1", Set.of(), Instant.parse("2020-02-20T20:20:20Z")); + @Order(4) + @DisplayName("GET /devices/device999 returns 200") + public void testGet999AfterCreate() { + given().when().get("/devices/{deviceId}", "device999") + .then().statusCode(200) + .body("id", is("device999")) + .body("name", is("Computer 999")); + } + + @Test + @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")); given().contentType(ContentType.JSON).body(deviceDto) .when().put("/devices/{deviceId}", "device999") .then().statusCode(201); + } - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - DELETE FROM "device" WHERE "id" = 'device999'; - """); - } + @Test + @Order(6) + @DisplayName("GET /devices/device999 returns 200 (with updated name)") + public void testGet999AfterUpdate() { + given().when().get("/devices/{deviceId}", "device999") + .then().statusCode(200) + .body("id", is("device999")) + .body("name", is("Computer 999 got a new name")); } @Test - @DisplayName("DELETE /devices/ returns 400") + @Order(7) + @DisplayName("DELETE /devices/ returns 400") public void testDeleteNoDeviceId() { - when().delete("/devices/{deviceId}", "\u0020") //a whitespace + when().delete("/devices/{deviceId}", " ") //a whitespace .then().statusCode(400); } @Test + @Order(7) @DisplayName("DELETE /devices/device0 returns 404") public void testDeleteNotExisting() { when().delete("/devices/{deviceId}", "device0") // @@ -111,6 +190,7 @@ public void testDeleteNotExisting() { } @Test + @Order(7) @DisplayName("DELETE /devices/device2 returns 404") public void testDeleteNotOwner() { when().delete("/devices/{deviceId}", "device2") // @@ -118,15 +198,9 @@ public void testDeleteNotOwner() { } @Test + @Order(7) @DisplayName("DELETE /devices/device999 returns 204") - public void testDeleteValid() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - INSERT INTO "device" ("id", "owner_id", "name", "type", "publickey", "creation_time") - VALUES ('device999', 'user1', 'To Be Deleted', 'DESKTOP', 'publickey1', '2020-02-20 20:20:20'); - """); - } - + public void testDeleteValid() { when().delete("/devices/{deviceId}", "device999") // .then().statusCode(204); } @@ -141,7 +215,7 @@ public class AsAnonymous { @Test @DisplayName("PUT /devices/device1 returns 401") public void testCreate1() { - var deviceDto = new DeviceResource.DeviceDto("device1", "Computer 1", Device.Type.DESKTOP, "publickey1", "user1", Set.of(), 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")); given().contentType(ContentType.JSON).body(deviceDto) .when().put("/devices/{deviceId}", "device1") diff --git a/backend/src/test/java/org/cryptomator/hub/api/GroupsResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/GroupsResourceTest.java new file mode 100644 index 000000000..2ef886ceb --- /dev/null +++ b/backend/src/test/java/org/cryptomator/hub/api/GroupsResourceTest.java @@ -0,0 +1,103 @@ +package org.cryptomator.hub.api; + +import io.agroal.api.AgroalDataSource; +import io.quarkus.test.junit.QuarkusTest; +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 io.restassured.http.ContentType; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.sql.SQLException; + +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; +import static org.hamcrest.CoreMatchers.hasItems; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.empty; + +@QuarkusTest +@DisplayName("Resource /groups") +public class GroupsResourceTest { + + @Inject + AgroalDataSource dataSource; + + @BeforeAll + public static void beforeAll() { + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + } + + @Nested + @DisplayName("As user1") + @TestSecurity(user = "User Name 1", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user1") + }) + public class AsAuthorzedUser1 { + + @Test + @DisplayName("GET /groups returns 200") + public void testGetAll() { + when().get("/groups") + .then().statusCode(200) + .body("id", hasItems("group1", "group2")); + } + + @Test + @DisplayName("GET /groups/group1/effective-members contains direct and subgroup members") + public void testGetEffectiveUsers() throws SQLException { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + s.execute(""" + INSERT INTO "authority" ("id", "type", "name") + VALUES + ('user999', 'USER', 'User 999'), + ('group999', 'GROUP', 'Group 999'); + + INSERT INTO "user_details" ("id") VALUES ('user999'); + INSERT INTO "group_details" ("id") VALUES ('group999'); + + INSERT INTO "group_membership" ("group_id", "member_id") + VALUES + ('group999', 'user999'), + ('group1', 'group999'); + """); + } + + when().get("/groups/{groupId}/effective-members", "group1") + .then().statusCode(200) + .body("id", hasItems("user1", "user999")); + + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + s.execute(""" + DELETE FROM "authority" WHERE "id" = 'user999' OR "id" = 'group999'; + """); + } + } + + } + + @Nested + @DisplayName("As unauthenticated user") + public class AsAnonymous { + + @DisplayName("401 Unauthorized") + @ParameterizedTest(name = "{0} {1}") + @CsvSource(value = { + "GET, /users" + }) + public void testGet(String method, String path) { + when().request(method, path) + .then().statusCode(401); + } + + } + +} \ No newline at end of file diff --git a/backend/src/test/java/org/cryptomator/hub/api/UsersResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/UsersResourceTest.java index 66128368c..150623494 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/UsersResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/UsersResourceTest.java @@ -59,18 +59,7 @@ public void testGetMe2() { when().get("/users/me?withDevices=true") .then().statusCode(200) .body("id", is("user1")) - .body("devices.id", hasItems("device1")) - .body("devices.accessTo.flatten()", empty()); - } - - @Test - @DisplayName("GET /users/me?withDevices=true&withAccessibleVaults=true returns 200") - public void testGetMe3() { - when().get("/users/me?withDevices=true&withAccessibleVaults=true") - .then().statusCode(200) - .body("id", is("user1")) - .body("devices.id", hasItems("device1")) - .body("devices.accessTo.id.flatten()", hasItems(equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100001111"))); + .body("devices.id", hasItems("device1")); } @Test diff --git a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java index 7dde2b88d..4383143f3 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java @@ -10,9 +10,9 @@ import io.restassured.RestAssured; import io.restassured.http.ContentType; import jakarta.inject.Inject; +import jakarta.transaction.Transactional; import jakarta.validation.Validator; -import org.cryptomator.hub.entities.Device; -import org.cryptomator.hub.filters.VaultAdminOnlyFilterProvider; +import org.cryptomator.hub.entities.Vault; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterAll; @@ -28,17 +28,19 @@ import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; +import java.security.GeneralSecurityException; import java.security.KeyFactory; +import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.interfaces.ECPrivateKey; +import java.security.spec.ECGenParameterSpec; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.sql.SQLException; import java.time.Instant; import java.util.Base64; import java.util.Map; -import java.util.Set; import java.util.UUID; import static io.restassured.RestAssured.given; @@ -55,10 +57,6 @@ @DisplayName("Resource /vaults") public class VaultResourceTest { - private static String vault1AdminJWT; - private static String vault2AdminJWT; - private static String vaultArchivedAdminJWT; - @Inject AgroalDataSource dataSource; @@ -66,16 +64,8 @@ public class VaultResourceTest { Validator validator; @BeforeAll - public static void beforeAll() throws NoSuchAlgorithmException, InvalidKeySpecException { + public static void beforeAll() { RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); - - var algorithmVault1 = Algorithm.ECDSA384((ECPrivateKey) getPrivateKey("MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAa57e0Q/KAqmIVOVcWX7b+Sm5YVNRUx8W7nc4wk1IBj2QJmsj+MeShQRHG4ozTE9KhZANiAASVL4lbdVoG9Wv0YpkafXf31YNN3rVD1/BAyZm4EYBg92X+taTvTlBjpaGWZuiSYRW9r+YQdKg1D3zAWb0UEKrOHjkgZ38MbBnTheGLlqH7VspuRWG12zydm0dF1ImiRik=")); - var algorithmVault2 = Algorithm.ECDSA384((ECPrivateKey) getPrivateKey("MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCAHpFQ62QnGCEvYh/pE9QmR1C9aLcDItRbslbmhen/h1tt8AyMhskeenT+rAyyPhGhZANiAAQLW5ZJePZzMIPAxMtZXkEWbDF0zo9f2n4+T1h/2sh/fviblc/VTyrv10GEtIi5qiOy85Pf1RRw8lE5IPUWpgu553SteKigiKLUPeNpbqmYZUkWGh3MLfVzLmx85ii2vMU=")); - var algorithmVaultArchived = Algorithm.ECDSA384((ECPrivateKey) getPrivateKey("MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCAHpFQ62QnGCEvYh/pE9QmR1C9aLcDItRbslbmhen/h1tt8AyMhskeenT+rAyyPhGhZANiAAQLW5ZJePZzMIPAxMtZXkEWbDF0zo9f2n4+T1h/2sh/fviblc/VTyrv10GEtIi5qiOy85Pf1RRw8lE5IPUWpgu553SteKigiKLUPeNpbqmYZUkWGh3MLfVzLmx85ii2vMU=")); - - vault1AdminJWT = JWT.create().withHeader(Map.of("vaultId", "7E57C0DE-0000-4000-8000-000100001111")).withIssuedAt(Instant.now()).sign(algorithmVault1); - vault2AdminJWT = JWT.create().withHeader(Map.of("vaultId", "7E57C0DE-0000-4000-8000-000100002222")).withIssuedAt(Instant.now()).sign(algorithmVault2); - vaultArchivedAdminJWT = JWT.create().withHeader(Map.of("vaultId", "7E57C0DE-0000-4000-8000-0001AAAAAAAA")).withIssuedAt(Instant.now()).sign(algorithmVaultArchived); } private static PrivateKey getPrivateKey(String keyBytes) throws NoSuchAlgorithmException, InvalidKeySpecException { @@ -111,9 +101,9 @@ public void testValidDto() { public class AsAuthorizedUser1 { @Test - @DisplayName("GET /vaults returns 200") + @DisplayName("GET /vaults/accessible returns 200") public void testGetSharedOrOwnedNotArchived() { - when().get("/vaults") + when().get("/vaults/accessible") .then().statusCode(200) .body("id", hasItems(equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100001111"), equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100002222"))); } @@ -134,42 +124,113 @@ public void testGetVault2() { } @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/members returns 401") - public void testGetAccess() { - when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100001111") - .then().statusCode(401); - } - - @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/device1 returns 200 using user access") + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/access-token returns 200 using user access") public void testUnlock1() { - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "device1") + when().get("/vaults/{vaultId}/access-token", "7E57C0DE-0000-4000-8000-000100001111") .then().statusCode(200) - .body(is("jwe1")); + .body(is("jwe.jwe.jwe.vault1.user1")); } @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/keys/device3 returns 200 using group access") + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/access-token returns 200 using group access") public void testUnlock2() { - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100002222", "device3") + when().get("/vaults/{vaultId}/access-token", "7E57C0DE-0000-4000-8000-000100002222") .then().statusCode(200) - .body(is("jwe3")); + .body(is("jwe.jwe.jwe.vault2.user1")); } @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/noSuchDevice returns 404") + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/access-token returns 200 using user access with evenIfArchived set") public void testUnlock3() { - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "noSuchDevice") - .then().statusCode(404); + when().get("/vaults/{vaultId}/access-token?evenIfArchived=true", "7E57C0DE-0000-4000-8000-000100001111") + .then().statusCode(200) + .body(is("jwe.jwe.jwe.vault1.user1")); + } + + @Test + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-00010000AAAA/access-token returns 410 for archived vaults") + public void testUnlockArchived1() { + when().get("/vaults/{vaultId}/access-token", "7E57C0DE-0000-4000-8000-00010000AAAA") + .then().statusCode(410); + } + + @Test + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-00010000AAAA/access-token returns 410 for archived vaults with evenIfArchived set to false") + public void testUnlockArchived2() { + when().get("/vaults/{vaultId}/access-token?evenIfArchived=false", "7E57C0DE-0000-4000-8000-00010000AAAA") + .then().statusCode(410); } @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/device2 returns 403") - public void testUnlock4() { - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "device2") + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-00010000AAAA/access-token returns 403 for archived vaults with evenIfArchived set to true") + public void testUnlockArchived3() throws SQLException { + when().get("/vaults/{vaultId}/access-token?evenIfArchived=true", "7E57C0DE-0000-4000-8000-00010000AAAA") .then().statusCode(403); } + @Nested + @DisplayName("legacy unlock") + @TestSecurity(user = "User Name 1", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user1") + }) + public class LegacyUnlock { + + @Test + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/legacyDevice1 returns 200 using user access") + public void testUnlock1() { + when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "legacyDevice1") + .then().statusCode(200) + .body(is("legacy.jwe.jwe.vault1.device1")); + } + + @Test + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/keys/legacyDevice3 returns 200 using group access") + public void testUnlock2() { + when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100002222", "legacyDevice3") + .then().statusCode(200) + .body(is("legacy.jwe.jwe.vault2.device3")); + } + + @Test + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/noSuchDevice returns 403") // legacy unlock must not encourage to register a legacy device by responding with 404 here + public void testUnlock3() { + when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "noSuchDevice") + .then().statusCode(403); + } + + @Test + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/legacyDevice2 returns 403") + public void testUnlock4() { + when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "legacyDevice2") + .then().statusCode(403); + } + + @Test + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-00010000AAAA/keys/someDevice returns 410 for archived vaults") + public void testUnlockArchived() { + when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-00010000AAAA", "legacyDevice1") + .then().statusCode(410); + } + + } + + } + + @Nested + @DisplayName("As user2") + @TestSecurity(user = "User Name 2", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user2") + }) + public class AsAuthorizedUser2 { + + @Test + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/access-token returns 449, because user2 is not initialized") + public void testUnlock() { + when().get("/vaults/{vaultId}/access-token", "7E57C0DE-0000-4000-8000-000100001111") + .then().statusCode(449); + } } @Nested @@ -178,26 +239,67 @@ public void testUnlock4() { @OidcSecurity(claims = { @Claim(key = "sub", value = "user1") }) - public class GetMembers { + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + public class CreateVaults { @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/members returns 200") - public void testGetAccess1() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100001111") - .then().statusCode(200) - .body("id", hasItems("user1", "user2")); + @Order(1) + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100003333 returns 201") + public void testCreateVault1() { + var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-000100003333"); + var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", "Test vault 3", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); + + given().contentType(ContentType.JSON).body(vaultDto) + .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100003333") + .then().statusCode(201) + .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100003333")) + .body("name", equalTo("My Vault")) + .body("description", equalTo("Test vault 3")) + .body("archived", equalTo(false)); } @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/members returns 400 when using incorrect 'Cryptomator-Vault-Admin-Authorization' header") - public void testGetAccess2() { - var vault2ButWrongKeyAdminJWT = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsInZhdWx0SWQiOiJ2YXVsdDIifQ.e30.cGZDCqzJQgcBHNVPcmBc8JfeGzUf3CHUrwSAMwOA0Dcy9aUZvsAm1dr1MKzuPW_UFHRfMnNi2EwASOA6t-vPWvPFolAHFn5REt2Y9Aw9mIz-qxSBLpz6OMZD16tysQcd"; - - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2ButWrongKeyAdminJWT) - .when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100002222") + @Order(1) + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-BADBADBADBAD returns 400 due to malformed request body") + public void testCreateVault2() { + given().contentType(ContentType.JSON) + .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-BADBADBADBAD") // invalid body (expected json) .then().statusCode(400); } + + @Test + @Order(1) + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100004444 returns 201 ignoring archived flag") + public void testCreateVault3() { + var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-000100004444"); + var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", "Test vault 4", true, Instant.parse("2112-12-21T21:12:21Z"), "masterkey4", 42, "NaCl", "authPubKey4", "authPrvKey4"); + + given().contentType(ContentType.JSON).body(vaultDto) + .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100004444") + .then().statusCode(201) + .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100004444")) + .body("name", equalTo("My Vault")) + .body("description", equalTo("Test vault 4")) + .body("archived", equalTo(false)); + } + + @Test + @Order(2) + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100003333 returns 200, updating only name, description and archive flag") + public void testUpdateVault() { + var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-000100003333"); + var vaultDto = new VaultResource.VaultDto(uuid, "VaultUpdated", "Vault updated.", true, Instant.parse("2222-11-11T11:11:11Z"), "doNotUpdate", 27, "doNotUpdate", "doNotUpdate", "doNotUpdate"); + given().contentType(ContentType.JSON) + .body(vaultDto) + .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100003333") + .then().statusCode(200) + .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100003333")) + .body("name", equalTo("VaultUpdated")) + .body("description", equalTo("Vault updated.")) + .body("archived", equalTo(true)) + .body("creationTime", not("2222-11-11T11:11:11Z")); + } + } @Nested @@ -210,66 +312,95 @@ public void testGetAccess2() { public class GrantAccess { @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/device3 returns 201") - public void testGrantAccess1() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .contentType(ContentType.TEXT).body("jwe.jwe.jwe.vault1.device3") - .when().put("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "device3") - .then().statusCode(201); + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100001111/access-tokens returns 404 for [user998, user999, user666]") + public void testGrantAccess0() throws SQLException { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + s.execute(""" + INSERT INTO "authority" ("id", "type", "name") VALUES ('user998', 'USER', 'User 998'); + INSERT INTO "authority" ("id", "type", "name") VALUES ('user999', 'USER', 'User 999'); + INSERT INTO "user_details" ("id") VALUES ('user998'); + INSERT INTO "user_details" ("id") VALUES ('user999'); + INSERT INTO "group_membership" ("group_id", "member_id") VALUES ('group2', 'user998'); + INSERT INTO "group_membership" ("group_id", "member_id") VALUES ('group2', 'user999'); + """); + } + + given().contentType(ContentType.JSON).body(Map.of("user998", "jwe.jwe.jwe.vault1.user998", "user999", "jwe.jwe.jwe.vault1.user999", "user666", "jwe.jwe.jwe.vault1.user666")) + .when().post("/vaults/{vaultId}/access-tokens/", "7E57C0DE-0000-4000-8000-000100001111") + .then().statusCode(404); + + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + s.execute(""" + DELETE FROM "authority" WHERE "id" = 'user998'; + DELETE FROM "authority" WHERE "id" = 'user999'; + """); + } + } + + @Test + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100001111/access-tokens returns 200 for [user998, user999]") + public void testGrantAccess1() throws SQLException { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + s.execute(""" + INSERT INTO "authority" ("id", "type", "name") VALUES ('user998', 'USER', 'User 998'); + INSERT INTO "authority" ("id", "type", "name") VALUES ('user999', 'USER', 'User 999'); + INSERT INTO "user_details" ("id") VALUES ('user998'); + INSERT INTO "user_details" ("id") VALUES ('user999'); + INSERT INTO "group_membership" ("group_id", "member_id") VALUES ('group2', 'user998'); + INSERT INTO "group_membership" ("group_id", "member_id") VALUES ('group2', 'user999'); + """); + } + + given().contentType(ContentType.JSON).body(Map.of("user998", "jwe.jwe.jwe.vault1.user998", "user999", "jwe.jwe.jwe.vault1.user999")) + .when().post("/vaults/{vaultId}/access-tokens/", "7E57C0DE-0000-4000-8000-000100001111") + .then().statusCode(200); + + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + s.execute(""" + DELETE FROM "authority" WHERE "id" = 'user998'; + DELETE FROM "authority" WHERE "id" = 'user999'; + """); + } } @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/device1 returns 409 due to user access already granted") + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100001111/access-tokens returns 200 for user1") public void testGrantAccess2() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .contentType(ContentType.TEXT).body("jwe1.jwe1.jwe1.jwe1.jwe1") - .when().put("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "device1") - .then().statusCode(409); + given().contentType(ContentType.JSON).body(Map.of("user1", "jwe.jwe.jwe.vault1.user1")) + .when().post("/vaults/{vaultId}/access-tokens/", "7E57C0DE-0000-4000-8000-000100001111") + .then().statusCode(200); } @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100002222/keys/device3 returns 409 due to group access already granted") - @TestSecurity(user = "User Name 2", roles = {"user"}) //we switch here for easy usage - @OidcSecurity(claims = { - @Claim(key = "sub", value = "user2") - }) + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-BADBADBADBAD/access-tokens returns 403 (not owning this vault)") public void testGrantAccess3() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .contentType(ContentType.TEXT).body("jwe3.jwe3.jwe3.jwe3.jwe3") - .when().put("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100002222", "device3") - .then().statusCode(409); + given().contentType(ContentType.JSON).body(Map.of("user1", "jwe.jwe.jwe.vault666.user1")) + .when().post("/vaults/{vaultId}/access-tokens/", "7E57C0DE-0000-4000-8000-BADBADBADBAD") + .then().statusCode(403); } @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/nonExistingDevice returns 404") + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100001111/access-tokens returns 404 for nonExistingUser") public void testGrantAccess4() { - given() - .header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .contentType(ContentType.TEXT).body("jwe3.jwe3.jwe3.jwe3.jwe3") - .when().put("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "nonExistingDevice") + given().contentType(ContentType.JSON).body(Map.of("user666", "jwe.jwe.jwe.vault1.user666")) + .when().post("/vaults/{vaultId}/access-tokens/", "7E57C0DE-0000-4000-8000-000100001111") .then().statusCode(404); } @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001AAAAAAAA/keys/someDevice returns 410") - public void testGrantAccessArchived() { - given() - .header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vaultArchivedAdminJWT) - .contentType(ContentType.TEXT).body("jwe3.jwe3.jwe3.jwe3.jwe3") - .when().put("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-0001AAAAAAAA", "someDevice") - .then().statusCode(410); + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100001111/access-tokens returns 400 for empty body") + public void testGrantAccess5() { + given().contentType(ContentType.JSON).body(Map.of()) + .when().post("/vaults/{vaultId}/access-tokens/", "7E57C0DE-0000-4000-8000-000100001111") + .then().statusCode(400); } - - @AfterAll - public void deleteData() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - DELETE FROM "access_token" - WHERE "device_id" = 'device3' - AND "vault_id" = '7E57C0DE-0000-4000-8000-000100001111'; - """); - } + @Test + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-00010000AAAA/access-tokens returns 410") + public void testGrantAccessArchived() { + given().contentType(ContentType.JSON).body(Map.of("user1", "jwe.jwe.jwe.vaultAAA.user1")) + .when().post("/vaults/{vaultId}/access-tokens/", "7E57C0DE-0000-4000-8000-00010000AAAA") + .then().statusCode(410); } } @@ -287,155 +418,133 @@ public class ManageAccessAsUser2 { @Order(1) @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100002222/users/user9999 returns 404 - no such user") public void addNonExistingUser() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().put("/vaults/{vaultId}/users/{userId}", "7E57C0DE-0000-4000-8000-000100002222", "user9999") + given().when().put("/vaults/{vaultId}/users/{userId}", "7E57C0DE-0000-4000-8000-000100002222", "user9999") .then().statusCode(404); } @Test @Order(2) - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-BADBADBADBAD/users/user2 returns 404 - no such vault") - public void addUserToNonExistingVault() throws NoSuchAlgorithmException, InvalidKeySpecException { - var algorithmVault = Algorithm.ECDSA384((ECPrivateKey) getPrivateKey("MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCAHpFQ62QnGCEvYh/pE9QmR1C9aLcDItRbslbmhen/h1tt8AyMhskeenT+rAyyPhGhZANiAAQLW5ZJePZzMIPAxMtZXkEWbDF0zo9f2n4+T1h/2sh/fviblc/VTyrv10GEtIi5qiOy85Pf1RRw8lE5IPUWpgu553SteKigiKLUPeNpbqmYZUkWGh3MLfVzLmx85ii2vMU=")); - var vaultAdminJWT = JWT.create().withHeader(Map.of("vaultId", "7E57C0DE-0000-4000-8000-BADBADBADBAD")).withIssuedAt(Instant.now()).sign(algorithmVault); - - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vaultAdminJWT) - .when().put("/vaults/{vaultId}/users/{userId}", "7E57C0DE-0000-4000-8000-BADBADBADBAD", "user2") - .then().statusCode(404); - } - - @Test - @Order(3) - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100002222/users/user9999 returns 401 - unauthenticated") - public void addNonExistingUserUnauthenticated() { - when().put("/vaults/{vaultId}/users/{userId}", "7E57C0DE-0000-4000-8000-000100002222", "user9999") - .then().statusCode(401); + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-BADBADBADBAD/users/user2 returns 403 - not owning a nonexisting vault") + public void addUserToNonExistingVault() { + given().when().put("/vaults/{vaultId}/users/{userId}", "7E57C0DE-0000-4000-8000-BADBADBADBAD", "user2") + .then().statusCode(403); } @Test @Order(4) @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/members does not contain user2") - public void getAccess1() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100002222") + public void getMembersOfVault2a() { + given().when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100002222") .then().statusCode(200) .body("users.id", not(hasItems("user2"))); } + @Test + @Order(4) + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/members returns 403") + public void getMembersOfVault1() { + when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100001111") + .then().statusCode(403); + } + @Test @Order(5) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/devices-requiring-access-grant does not contains device2") - public void testGetDevicesRequiringAccess1() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().get("/vaults/{vaultId}/devices-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100002222") + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/users-requiring-access-grant does contains user2 via group membership") + public void testGetUsersRequiringAccess1() throws SQLException { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + s.execute(""" + UPDATE + "user_details" SET publickey='public2', privatekey='private2', setupcode='setup2' + WHERE id='user2'; + """); + } + + given().when().get("/vaults/{vaultId}/users-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100002222") .then().statusCode(200) - .body("id", not(hasItems("device2"))); + .body("id", hasItems("user2")); + + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + s.execute(""" + UPDATE + "user_details" SET publickey=NULL, privatekey=NULL, setupcode=NULL + WHERE id='user2'; + """); + } } @Test @Order(6) @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100002222/members/user2 returns 201") - public void testGrantAccess() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().put("/vaults/{vaultId}/users/{userId}", "7E57C0DE-0000-4000-8000-000100002222", "user2") + public void testGrantDirectAccessToSelf() { + given().when().put("/vaults/{vaultId}/users/{userId}", "7E57C0DE-0000-4000-8000-000100002222", "user2") .then().statusCode(201); } @Test @Order(7) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/members does contain user2") - public void getMembers2() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100002222") + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/members does contain user2 directly") + public void getMembersOfVault2b() { + given().when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100002222") .then().statusCode(200) .body("id", hasItems("user2")); } - @Test - @Order(8) - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100002222/users/user2 returns 409 - user2 already direct member of vault2") - public void testGrantAccessAgain() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().put("/vaults/{vaultId}/users/{usersId}", "7E57C0DE-0000-4000-8000-000100002222", "user2") - .then().statusCode(409); - } - - @Test - @Order(9) - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100002222/groups/group1 returns 409 - group1 already direct member of vault2") - public void testAddingMemberAgainFails() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().put("/vaults/{vaultId}/groups/{groupId}", "7E57C0DE-0000-4000-8000-000100002222", "group1") - .then().statusCode(409); - } - @Test @Order(10) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/devices-requiring-access-grant contains device2") - public void testGetDevicesRequiringAccess2() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().get("/vaults/{vaultId}/devices-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100002222") + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/users-requiring-access-grant contains user2") + public void testGetUsersRequiringAccess2() throws SQLException { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + s.execute(""" + UPDATE + "user_details" SET publickey='public2', privatekey='private2', setupcode='setup2' + WHERE id='user2'; + """); + } + + given().when().get("/vaults/{vaultId}/users-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100002222") .then().statusCode(200) - .body("id", hasItems("device2")); + .body("id", hasItems("user2")); + + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + s.execute(""" + UPDATE + "user_details" SET publickey=NULL, privatekey=NULL, setupcode=NULL + WHERE id='user2'; + """); + } } @Test @Order(11) - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100002222/keys/device2 returns 201") - public void testGrantAccess1() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .given().contentType(ContentType.TEXT).body("jwe.jwe.jwe.vault2.device2") - .when().put("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100002222", "device2") - .then().statusCode(201); + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100002222/access-tokens for user2 returns 200") + public void testGrantAccess() { + given().contentType(ContentType.JSON).body(Map.of("user2", "jwe.jwe.jwe.vault2.user2")) + .when().post("/vaults/{vaultId}/access-tokens/", "7E57C0DE-0000-4000-8000-000100002222") + .then().statusCode(200); } @Test @Order(12) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/devices-requiring-access-grant contains not device2") - public void testGetDevicesRequiringAccess3() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().get("/vaults/{vaultId}/devices-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100002222") + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/users-requiring-access-grant contains not user2") + public void testGetUsersRequiringAccess3() { + given().when().get("/vaults/{vaultId}/users-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100002222") .then().statusCode(200) - .body("id", not(hasItems("device2"))); + .body("id", not(hasItems("user2"))); } @Test @Order(13) - @DisplayName("PUT /devices/device9999 returns 201") - public void testCreateDevice2() { - var deviceDto = new DeviceResource.DeviceDto("device9999", "Computer 9999", Device.Type.DESKTOP, "publickey9999", "user2", Set.of(), Instant.parse("2020-02-20T20:20:20Z")); - - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .given().contentType(ContentType.JSON).body(deviceDto) - .when().put("/devices/{deviceId}", "device9999") - .then().statusCode(201); - } - - @Test - @Order(14) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/devices-requiring-access-grant contains not device9999") - public void testGetDevicesRequiringAccess4() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().get("/vaults/{vaultId}/devices-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100002222") - .then().statusCode(200) - .body("id", hasItems("device9999")); - } - - @Test - @Order(15) @DisplayName("DELETE /vaults/7E57C0DE-0000-4000-8000-000100002222/members/user2 returns 204") public void testRevokeAccess() { // previously added in testGrantAccess() - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().delete("/vaults/{vaultId}/users/{userId}", "7E57C0DE-0000-4000-8000-000100002222", "user2") + given().when().delete("/vaults/{vaultId}/authority/{userId}", "7E57C0DE-0000-4000-8000-000100002222", "user2") .then().statusCode(204); } @Test - @Order(16) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/access does not contain user2") - public void getMembers3() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100002222") + @Order(14) + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/members does not contain user2") + public void getMembersOfVault2c() { + given().when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100002222") .then().statusCode(200) .body("id", not(hasItems("user2"))); } @@ -451,12 +560,23 @@ public void getMembers3() { @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class ManageAccessAsUser1 { + @BeforeAll + public void setup() throws SQLException { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + // user999 will be deleted in #cleanup() + s.execute(""" + INSERT INTO "authority" ("id", "type", "name") VALUES ('user999', 'USER', 'User 999'); + INSERT INTO "user_details" ("id", "publickey", "privatekey", "setupcode") VALUES ('user999', 'public999', 'private999', 'setup999'); + INSERT INTO "group_membership" ("group_id", "member_id") VALUES ('group2', 'user999') + """); + } + } + @Test @Order(1) @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/groups/group3000 returns 404") public void addNonExistingGroup() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .when().put("/vaults/{vaultId}/groups/{groupId}", "7E57C0DE-0000-4000-8000-000100001111", "group3000") + given().when().put("/vaults/{vaultId}/groups/{groupId}", "7E57C0DE-0000-4000-8000-000100001111", "group3000") .then().statusCode(404); } @@ -464,84 +584,76 @@ public void addNonExistingGroup() { @Order(2) @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/groups/group2 returns 201") public void addGroupToVault() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .when().put("/vaults/{vaultId}/groups/{groupId}", "7E57C0DE-0000-4000-8000-000100001111", "group2") + given().when().put("/vaults/{vaultId}/groups/{groupId}", "7E57C0DE-0000-4000-8000-000100001111", "group2") .then().statusCode(201); } @Test @Order(3) @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/members does contain group2") - public void getMembers1() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100001111") + public void getMembersOfVault1a() { + given().when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100001111") .then().statusCode(200) .body("id", hasItems("group2")); } @Test - @Order(4) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/devices-requiring-access-grant contains device999") - public void testGetDevicesRequiringAccess3() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - // device999 will be deleted in #cleanup() - s.execute(""" - INSERT INTO "device" ("id", "owner_id", "name", "publickey", "creation_time") - VALUES - ('device999', 'user2', 'Computer 999', 'publickey90', '2020-02-20 20:20:20'); - """); - } + @Order(3) + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/members returns 403") + public void getMembersOfVault2() { + given().when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100002222") + .then().statusCode(403); + } - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .when().get("/vaults/{vaultId}/devices-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100001111") + @Test + @Order(4) + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/users-requiring-access-grant contains user999") + public void testGetUsersRequiringAccess3() { + given().when().get("/vaults/{vaultId}/users-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100001111") .then().statusCode(200) - .body("id", hasItems("device999")); + .body("id", hasItems("user999")); } @Test @Order(5) - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/device999 returns 201") + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100001111/access-tokens for user999 returns 200") public void testGrantAccess2() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .contentType(ContentType.TEXT).body("jwe.jwe.jwe.vault2.device93") - .when().put("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "device999") - .then().statusCode(201); + given().contentType(ContentType.JSON).body(Map.of("user999", "jwe.jwe.jwe.vault2.user999")) + .when().post("/vaults/{vaultId}/access-tokens/", "7E57C0DE-0000-4000-8000-000100001111") + .then().statusCode(200); } @Test @Order(6) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/devices-requiring-access-grant contains not device999") - public void testGetDevicesRequiringAccess4() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .when().get("/vaults/{vaultId}/devices-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100001111") + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/users-requiring-access-grant does no longer contain user999") + public void testGetUsersRequiringAccess4() { + given().when().get("/vaults/{vaultId}/users-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100001111") .then().statusCode(200) - .body("id", not(hasItems("device999"))); + .body("id", not(hasItems("user999"))); } @Test @Order(7) @DisplayName("DELETE /vaults/7E57C0DE-0000-4000-8000-000100001111/groups/group2 returns 204") public void removeGroup2() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .when().delete("/vaults/{vaultId}/groups/{groupId}", "7E57C0DE-0000-4000-8000-000100001111", "group2") + given().when().delete("/vaults/{vaultId}/authority/{groupId}", "7E57C0DE-0000-4000-8000-000100001111", "group2") .then().statusCode(204); } @Test @Order(8) @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/members does not contain group2") - public void getMembers2() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100001111") + public void getMembersOfVault1b() { + given().when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100001111") .then().statusCode(200) .body("id", not(hasItems("group2"))); } @AfterAll public void cleanup() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { s.execute(""" - DELETE FROM "device" WHERE ID = 'device999'; + DELETE FROM "authority" WHERE ID = 'user999'; """); } } @@ -561,7 +673,7 @@ public class ExceedingLicenseLimits { @BeforeAll public void setup() throws SQLException { //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() == 2); - try (var s = dataSource.getConnection().createStatement()) { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { s.execute(""" INSERT INTO "authority" ("id", "type", "name") VALUES @@ -576,7 +688,6 @@ public void setup() throws SQLException { VALUES ('group91'); - INSERT INTO "user_details" ("id") VALUES ('user91'), @@ -594,7 +705,7 @@ public void setup() throws SQLException { INSERT INTO "vault_access" ("vault_id", "authority_id") VALUES - ('7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'user95_A'); + ('7E57C0DE-0000-4000-8000-00010000AAAA', 'user95_A'); """); } } @@ -605,20 +716,18 @@ public void setup() throws SQLException { public void addGroupToVaultExceedingSeats() { //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() == 2); - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .when().put("/vaults/{vaultId}/groups/{groupId}", "7E57C0DE-0000-4000-8000-000100001111", "group91") + given().when().put("/vaults/{vaultId}/groups/{groupId}", "7E57C0DE-0000-4000-8000-000100001111", "group91") .then().statusCode(402); } @Order(2) @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/users/userXX returns 201") - @ParameterizedTest(name = "Adding user {index} succeeds") + @ParameterizedTest(name = "Adding user {0} succeeds") @ValueSource(strings = {"user91", "user92", "user93"}) public void addUserToVaultNotExceedingSeats(String userId) { //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() == 2); - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .when().put("/vaults/{vaultId}/users/{usersId}", "7E57C0DE-0000-4000-8000-000100001111", userId) + given().when().put("/vaults/{vaultId}/users/{usersId}", "7E57C0DE-0000-4000-8000-000100001111", userId) .then().statusCode(201); } @@ -628,16 +737,67 @@ public void addUserToVaultNotExceedingSeats(String userId) { public void addUserToVaultExceedingSeats() { //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() == 5); - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .when().put("/vaults/{vaultId}/users/{usersId}", "7E57C0DE-0000-4000-8000-000100001111", "user94") + given().when().put("/vaults/{vaultId}/users/{usersId}", "7E57C0DE-0000-4000-8000-000100001111", "user94") .then().statusCode(402); } @Test + @TestSecurity(user = "User 94", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user94") + }) @Order(4) + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF3333 (as user94) exceeding the license returns 402") + public void testCreateVaultExceedingSeats() { + //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() > 5); + + var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF3333"); + var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", "Test vault 4", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); + given().contentType(ContentType.JSON).body(vaultDto) + .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF3333") + .then().statusCode(402); + } + + @Test + @Order(5) + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF3333 (as user1) returns 201 not exceeding seats because user already has access to an existing vault") + public void testCreateVaultNotExceedingSeats() { + //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() > 5); + + var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF3333"); + var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", "Test vault 3", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); + given().contentType(ContentType.JSON).body(vaultDto) + .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF3333") + .then().statusCode(201) + .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001FFFF3333")) + .body("name", equalTo("My Vault")) + .body("description", equalTo("Test vault 3")) + .body("archived", equalTo(false)); + } + + @Test + @Order(6) + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF3333 (as user1) returns 200 with only updated name, description and archive flag, despite exceeding license") + public void testUpdateVaultDespiteLicenseExceeded() { + //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() > 5); + + var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF3333"); + var vaultDto = new VaultResource.VaultDto(uuid, "VaultUpdated", "Vault updated.", true, Instant.parse("2222-11-11T11:11:11Z"), "someVaule", -1, "doNotUpdate", "doNotUpdate", "doNotUpdate"); + given().contentType(ContentType.JSON) + .body(vaultDto) + .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF3333") + .then().statusCode(200) + .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001FFFF3333")) + .body("name", equalTo("VaultUpdated")) + .body("description", equalTo("Vault updated.")) + .body("archived", equalTo(true)); + } + + @Test + @Order(7) @DisplayName("Unlock is blocked if exceeding license seats") public void testUnlockBlockedIfLicenseExceeded() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { s.execute(""" INSERT INTO "vault_access" ("vault_id", "authority_id") VALUES ('7E57C0DE-0000-4000-8000-000100001111', 'group91'); @@ -645,13 +805,13 @@ public void testUnlockBlockedIfLicenseExceeded() throws SQLException { } //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() > 5); - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "device1") + when().get("/vaults/{vaultId}/access-token", "7E57C0DE-0000-4000-8000-000100001111") .then().statusCode(402); } @AfterAll public void reset() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { s.execute(""" DELETE FROM "authority" WHERE "id" IN ('user91', 'user92', 'user93', 'user94', 'user95_A', 'group91'); @@ -661,6 +821,208 @@ public void reset() throws SQLException { } + @Nested + @DisplayName("Claim Ownership") + @TestSecurity(user = "User Name 1", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user1") + }) + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + public class ClaimOwnership { + + private static Algorithm JWT_ALG; + + @BeforeAll + @Transactional + public static void setup() throws GeneralSecurityException { + var keyPairGen = KeyPairGenerator.getInstance("EC"); + keyPairGen.initialize(new ECGenParameterSpec("secp384r1")); + var keyPair = keyPairGen.generateKeyPair(); + JWT_ALG = Algorithm.ECDSA384((ECPrivateKey) keyPair.getPrivate()); + + Vault v = new Vault(); + v.id = UUID.fromString("7E57C0DE-0000-4000-8000-000100009999"); + v.name = "ownership-test-vault"; + v.creationTime = Instant.now(); + v.authenticationPublicKey = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + v.persist(); + } + + @Test + @Order(1) + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100009999/claim-ownership returns 400 - JWT has wrong SUB") + public void testClaimOwnershipIncorrectJWT1() { + var proof = JWT.create() + .withNotBefore(Instant.now().minusSeconds(10)) + .withExpiresAt(Instant.now().plusSeconds(10)) + .withSubject("userBAD") + .withClaim("vaultId", "7E57C0DE-0000-4000-8000-000100009999".toLowerCase()) + .sign(JWT_ALG); + + given().param("proof", proof) + .when().post("/vaults/{vaultId}/claim-ownership", "7E57C0DE-0000-4000-8000-000100009999") + .then().statusCode(400); + } + + @Test + @Order(1) + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100009999/claim-ownership returns 400 - JWT missing NBF") + public void testClaimOwnershipIncorrectJWT2() { + var proof = JWT.create() + .withExpiresAt(Instant.now().plusSeconds(10)) + .withSubject("user1") + .withClaim("vaultId", "7E57C0DE-0000-4000-8000-000100009999".toLowerCase()) + .sign(JWT_ALG); + + given().param("proof", proof) + .when().post("/vaults/{vaultId}/claim-ownership", "7E57C0DE-0000-4000-8000-000100009999") + .then().statusCode(400); + } + + @Test + @Order(1) + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100009999/claim-ownership returns 400 - JWT not yet valid") + public void testClaimOwnershipIncorrectJWT3() { + var proof = JWT.create() + .withNotBefore(Instant.now().plusSeconds(60)) + .withExpiresAt(Instant.now().plusSeconds(10)) + .withSubject("user1") + .withClaim("vaultId", "7E57C0DE-0000-4000-8000-000100009999".toLowerCase()) + .sign(JWT_ALG); + + given().param("proof", proof) + .when().post("/vaults/{vaultId}/claim-ownership", "7E57C0DE-0000-4000-8000-000100009999") + .then().statusCode(400); + } + + @Test + @Order(1) + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100009999/claim-ownership returns 400 - JWT missing EXP") + public void testClaimOwnershipIncorrectJWT4() { + var proof = JWT.create() + .withNotBefore(Instant.now().minusSeconds(10)) + .withSubject("user1") + .withClaim("vaultId", "7E57C0DE-0000-4000-8000-000100009999".toLowerCase()) + .sign(JWT_ALG); + + given().param("proof", proof) + .when().post("/vaults/{vaultId}/claim-ownership", "7E57C0DE-0000-4000-8000-000100009999") + .then().statusCode(400); + } + + @Test + @Order(1) + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100009999/claim-ownership returns 400 - JWT expired") + public void testClaimOwnershipIncorrectJWT5() { + var proof = JWT.create() + .withNotBefore(Instant.now().minusSeconds(10)) + .withExpiresAt(Instant.now().minusSeconds(60)) + .withSubject("user1") + .withClaim("vaultId", "7E57C0DE-0000-4000-8000-000100009999".toLowerCase()) + .sign(JWT_ALG); + + given().param("proof", proof) + .when().post("/vaults/{vaultId}/claim-ownership", "7E57C0DE-0000-4000-8000-000100009999") + .then().statusCode(400); + } + + @Test + @Order(1) + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100009999/claim-ownership returns 400 - JWT wrong vaultId") + public void testClaimOwnershipIncorrectJWT6() { + var proof = JWT.create() + .withNotBefore(Instant.now().minusSeconds(10)) + .withExpiresAt(Instant.now().plusSeconds(10)) + .withSubject("user1") + .withClaim("vaultId", "wrong") + .sign(JWT_ALG); + + given().param("proof", proof) + .when().post("/vaults/{vaultId}/claim-ownership", "7E57C0DE-0000-4000-8000-000100009999") + .then().statusCode(400); + } + + @Test + @Order(1) + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100009999/claim-ownership returns 400 - JWT wrong signature") + public void testClaimOwnershipIncorrectJWT7() throws GeneralSecurityException { + var keyPairGen = KeyPairGenerator.getInstance("EC"); + keyPairGen.initialize(new ECGenParameterSpec("secp384r1")); + var differentKey = keyPairGen.generateKeyPair(); + var alg = Algorithm.ECDSA384((ECPrivateKey) differentKey.getPrivate()); + + var proof = JWT.create() + .withNotBefore(Instant.now().minusSeconds(10)) + .withExpiresAt(Instant.now().plusSeconds(10)) + .withSubject("user1") + .withClaim("vaultId", "7E57C0DE-0000-4000-8000-000100009999".toLowerCase()) + .sign(alg); + + given().param("proof", proof) + .when().post("/vaults/{vaultId}/claim-ownership", "7E57C0DE-0000-4000-8000-000100009999") + .then().statusCode(400); + } + + @Test + @Order(1) + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-BADBADBADBAD/claim-ownership returns 404") + public void testClaimOwnershipNoSuchVault() { + var proof = JWT.create() + .withJWTId(UUID.randomUUID().toString()) + .withSubject("user1") + .withClaim("vaultId", "7E57C0DE-0000-4000-8000-000100009999".toLowerCase()) + .withIssuedAt(Instant.now()) + .sign(JWT_ALG); + + given().param("proof", proof) + .when().post("/vaults/{vaultId}/claim-ownership", "7E57C0DE-0000-4000-8000-BADBADBADBAD") + .then().statusCode(404); + } + + @Test + @Order(2) + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100009999/claim-ownership returns 200") + public void testClaimOwnershipSuccess() { + var proof = JWT.create() + .withNotBefore(Instant.now().minusSeconds(10)) + .withExpiresAt(Instant.now().plusSeconds(10)) + .withSubject("user1") + .withClaim("vaultId", "7E57C0DE-0000-4000-8000-000100009999".toLowerCase()) + .sign(JWT_ALG); + + given().param("proof", proof) + .when().post("/vaults/{vaultId}/claim-ownership", "7E57C0DE-0000-4000-8000-000100009999") + .then().statusCode(200); + } + + @Test + @Order(3) + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100009999/claim-ownership returns 409") + public void testClaimOwnershipAlreadyClaimed() { + var proof = JWT.create() + .withNotBefore(Instant.now().minusSeconds(10)) + .withExpiresAt(Instant.now().plusSeconds(10)) + .withSubject("user1") + .withClaim("vaultId", "7E57C0DE-0000-4000-8000-000100009999".toLowerCase()) + .sign(JWT_ALG); + + given().param("proof", proof) + .when().post("/vaults/{vaultId}/claim-ownership", "7E57C0DE-0000-4000-8000-000100009999") + .then().statusCode(409); + } + + @AfterAll + public void reset() throws SQLException { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + s.execute(""" + DELETE FROM "vault" WHERE "id" = '7E57C0DE-0000-4000-8000-000100009999'; + """); + } + } + + } + @Nested @DisplayName("As unauthenticated user") public class AsAnonymous { @@ -668,13 +1030,13 @@ public class AsAnonymous { @DisplayName("401 Unauthorized") @ParameterizedTest(name = "{0} {1}") @CsvSource(value = { - "GET, /vaults", + "GET, /vaults/accessible", "GET, /vaults/7E57C0DE-0000-4000-8000-000100001111", "GET, /vaults/7E57C0DE-0000-4000-8000-000100001111/members", "PUT, /vaults/7E57C0DE-0000-4000-8000-000100001111/users/user1", - "DELETE, /vaults/7E57C0DE-0000-4000-8000-000100001111/users/user1", - "GET, /vaults/7E57C0DE-0000-4000-8000-000100001111/devices-requiring-access-grant", - "GET, /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/device1" + "DELETE, /vaults/7E57C0DE-0000-4000-8000-000100001111/authority/user1", + "GET, /vaults/7E57C0DE-0000-4000-8000-000100001111/users-requiring-access-grant", + "GET, /vaults/7E57C0DE-0000-4000-8000-000100001111/access-token" }) public void testGet(String method, String path) { when().request(method, path) @@ -707,41 +1069,10 @@ public void testGetAllVaultsAsUser() { public void testGetAllVaultsAsAdmin() { when().get("/vaults/all") .then().statusCode(200) - .body("id", hasItems(equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100001111"), equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100002222"), equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001AAAAAAAA"))); + .body("id", hasItems(equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100001111"), equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100002222"), equalToIgnoringCase("7E57C0DE-0000-4000-8000-00010000AAAA"))); } } - @Nested - @DisplayName("GET /vaults/{vaultid}/keys/{deviceId}") - @TestSecurity(user = "User Name 1", roles = {"user"}) - @OidcSecurity(claims = { - @Claim(key = "sub", value = "user1") - }) - public class Unlock { - - @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/iDoNotExist returns 404 for not-existing device") - public void testUnlockNotExistingDevice() { - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-BADBADBADBAD", "iDoNotExist") - .then().statusCode(404); - } - - @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-BADBADBADBAD/keys/someDevice returns 404 for not-existing vaults") - public void testUnlockNotExistingVault() { - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-BADBADBADBAD", "someDevice") - .then().statusCode(404); - } - - @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-0001AAAAAAAA/keys/someDevice returns 410 for archived vaults") - public void testUnlockArchived() { - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-0001AAAAAAAA", "someDevice") - .then().statusCode(410); - } - - } - @Nested @DisplayName("/vaults/some") public class GetSomeVaults { @@ -793,266 +1124,4 @@ public void testListSomeVaultsAsUser() { .then().statusCode(403); } } - - @Nested - @DisplayName("PUT /vaults/{vaultid}") - @TestSecurity(user = "User Name 1", roles = {"user"}) - @OidcSecurity(claims = { - @Claim(key = "sub", value = "user1") - }) - @TestInstance(TestInstance.Lifecycle.PER_CLASS) - public class CreateOrUpdate { - - @BeforeAll - public void insertData() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - INSERT INTO "vault" ("id", "name", "description", "creation_time", "salt", "iterations", "masterkey", "auth_pubkey", "auth_prvkey", "archived") - VALUES - ('7E57C0DE-0000-4000-8000-0001FFFF1111', 'Vault U', 'Vault to update.', - '2020-02-20T20:20:20Z', 'saltU', 42, 'masterkeyU', 'authPubKeyU', 'authPrvKeyU', FALSE); - - INSERT INTO "authority" ("id", "type", "name") - VALUES - ('user96', 'USER', 'user name 96'), - ('user97', 'USER', 'user name 97'); - - INSERT INTO "user_details" ("id") - VALUES - ('user96'), - ('user97'); - """); - - } - } - - @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF2222 returns 201") - public void testCreateVault1() { - var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF2222"); - var vaultDto = new VaultResource.VaultDto(uuid, "Test Vault", "Vault to create", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkeyC", 42, "saltC", "authPubKeyC", "authPrvKeyC"); - given().contentType(ContentType.JSON).body(vaultDto) - .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF2222") - .then().statusCode(201) - .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001FFFF2222")) - .body("name", equalTo("Test Vault")) - .body("description", equalTo("Vault to create")) - .body("masterkey", equalTo("masterkeyC")) - .body("salt", equalTo("saltC")) - .body("iterations", equalTo(42)) - .body("authPublicKey", equalTo("authPubKeyC")) - .body("authPrivateKey", equalTo("authPrvKeyC")) - .body("archived", equalTo(false)); - } - - @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-BADBADBADBAD returns 400") - public void testCreateVault2() { - given().contentType(ContentType.JSON) - .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-BADBADBADBAD") - .then().statusCode(400); - } - - @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF3333 returns 201 not exceeding seats but user does not have a vault yet") - @TestSecurity(user = "User Name 96", roles = {"user"}) - @OidcSecurity(claims = { - @Claim(key = "sub", value = "user96") - }) - public void testCreateVault3() { - var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF3333"); - var vaultDto = new VaultResource.VaultDto(uuid, "Test Vault", "Vault to create", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkeyC", 42, "saltC", "authPubKeyC", "authPrvKeyC"); - given().contentType(ContentType.JSON).body(vaultDto) - .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF3333") - .then().statusCode(201) - .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001FFFF3333")) - .body("name", equalTo("Test Vault")) - .body("description", equalTo("Vault to create")) - .body("masterkey", equalTo("masterkeyC")) - .body("salt", equalTo("saltC")) - .body("iterations", equalTo(42)) - .body("authPublicKey", equalTo("authPubKeyC")) - .body("authPrivateKey", equalTo("authPrvKeyC")) - .body("archived", equalTo(false)); - } - - @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF4444 exceeding the license returns 402") - @TestSecurity(user = "User Name 97", roles = {"user"}) - @OidcSecurity(claims = { - @Claim(key = "sub", value = "user97") - }) - public void testCreateVault4() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - INSERT INTO "authority" ("id", "type", "name") - VALUES - ('user91', 'USER', 'user name 91'), - ('user92', 'USER', 'user name 92'), - ('user93', 'USER', 'user name 93'), - ('user94', 'USER', 'user name 94'), - ('user95_A', 'USER', 'user name Archived'), - ('group91', 'GROUP', 'group name 91'); - - INSERT INTO "group_details" ("id") - VALUES - ('group91'); - - INSERT INTO "user_details" ("id") - VALUES - ('user91'), - ('user92'), - ('user93'), - ('user94'), - ('user95_A'); - - INSERT INTO "group_membership" ("group_id", "member_id") - VALUES - ('group91', 'user91'), - ('group91', 'user92'), - ('group91', 'user93'), - ('group91', 'user94'); - - INSERT INTO "vault_access" ("vault_id", "authority_id") - VALUES - ('7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'user95_A'), - ('7E57C0DE-0000-4000-8000-000100001111', 'group91'); - """); - } - //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() > 5); - - var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF4444"); - var vaultDto = new VaultResource.VaultDto(uuid, "Test Vault", "Vault to create", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkeyC", 42, "saltC", "authPubKeyC", "authPrvKeyC"); - given().contentType(ContentType.JSON).body(vaultDto) - .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF4444") - .then().statusCode(402); - - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - DELETE FROM "authority" - WHERE "id" IN ('user91', 'user92', 'user93', 'user94', 'user95_A', 'group91'); - """); - } - } - - @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFFAAAA returns 201 ignoring archived flag") - public void testCreateVaultIgnoringArchived() { - var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFFAAAA"); - var vaultDto = new VaultResource.VaultDto(uuid, "Test Vault", "Vault to create", true, Instant.parse("2112-12-21T21:12:21Z"), "masterkeyC", 42, "saltC", "authPubKeyC", "authPrvKeyC"); - given().contentType(ContentType.JSON).body(vaultDto) - .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFFAAAA") - .then().statusCode(201) - .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001FFFFAAAA")) - .body("name", equalTo("Test Vault")) - .body("description", equalTo("Vault to create")) - .body("masterkey", equalTo("masterkeyC")) - .body("salt", equalTo("saltC")) - .body("iterations", equalTo(42)) - .body("authPublicKey", equalTo("authPubKeyC")) - .body("authPrivateKey", equalTo("authPrvKeyC")) - .body("archived", equalTo(false)); - } - - @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF1111 returns 200 with only updated name, description and archive flag") - public void testUpdateVault() { - var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF1111"); - var vaultDto = new VaultResource.VaultDto(uuid, "VaultUpdated", "Vault updated.", true, Instant.parse("2112-12-21T21:12:21Z"), "someVaule", -1, "someVaule", "someValue", "someValue"); - given().contentType(ContentType.JSON) - .body(vaultDto) - .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF1111") - .then().statusCode(200) - .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001FFFF1111")) - .body("name", equalTo("VaultUpdated")) - .body("description", equalTo("Vault updated.")) - .body("creationTime", equalTo("2020-02-20T20:20:20Z")) - .body("masterkey", equalTo("masterkeyU")) - .body("salt", equalTo("saltU")) - .body("iterations", equalTo(42)) - .body("authPublicKey", equalTo("authPubKeyU")) - .body("authPrivateKey", equalTo("authPrvKeyU")) - .body("archived", equalTo(true)); - } - - @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF1111 returns 200 with only updated name, description and archive flag, exceeding license") - public void testUpdateVault1() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - INSERT INTO "authority" ("id", "type", "name") - VALUES - ('user91', 'USER', 'user name 91'), - ('user92', 'USER', 'user name 92'), - ('user93', 'USER', 'user name 93'), - ('user94', 'USER', 'user name 94'), - ('user95_A', 'USER', 'user name Archived'), - ('group91', 'GROUP', 'group name 91'); - - INSERT INTO "group_details" ("id") - VALUES - ('group91'); - - INSERT INTO "user_details" ("id") - VALUES - ('user91'), - ('user92'), - ('user93'), - ('user94'), - ('user95_A'); - - INSERT INTO "group_membership" ("group_id", "member_id") - VALUES - ('group91', 'user91'), - ('group91', 'user92'), - ('group91', 'user93'), - ('group91', 'user94'); - - INSERT INTO "vault_access" ("vault_id", "authority_id") - VALUES - ('7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'user95_A'), - ('7E57C0DE-0000-4000-8000-000100001111', 'group91'); - """); - } - //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() > 5); - - var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF1111"); - var vaultDto = new VaultResource.VaultDto(uuid, "VaultUpdated", "Vault updated.", true, Instant.parse("2112-12-21T21:12:21Z"), "someVaule", -1, "someVaule", "someValue", "someValue"); - given().contentType(ContentType.JSON) - .body(vaultDto) - .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF1111") - .then().statusCode(200) - .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001FFFF1111")) - .body("name", equalTo("VaultUpdated")) - .body("description", equalTo("Vault updated.")) - .body("creationTime", equalTo("2020-02-20T20:20:20Z")) - .body("masterkey", equalTo("masterkeyU")) - .body("salt", equalTo("saltU")) - .body("iterations", equalTo(42)) - .body("authPublicKey", equalTo("authPubKeyU")) - .body("authPrivateKey", equalTo("authPrvKeyU")) - .body("archived", equalTo(true)); - - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - DELETE FROM "authority" - WHERE "id" IN ('user91', 'user92', 'user93', 'user94', 'user95_A', 'group91'); - """); - } - } - - @AfterAll - public void deleteData() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - DELETE FROM "vault" - WHERE "id" IN ('7E57C0DE-0000-4000-8000-0001FFFF1111','7E57C0DE-0000-4000-8000-0001FFFF2222'); - - DELETE FROM "authority" - WHERE "id" IN ('user96', 'user97'); - """); - } - } - - } -} \ No newline at end of file +} diff --git a/backend/src/test/java/org/cryptomator/hub/entities/EntityIntegrationTest.java b/backend/src/test/java/org/cryptomator/hub/entities/EntityIntegrationTest.java index 6c3d224f0..a54560dee 100644 --- a/backend/src/test/java/org/cryptomator/hub/entities/EntityIntegrationTest.java +++ b/backend/src/test/java/org/cryptomator/hub/entities/EntityIntegrationTest.java @@ -1,11 +1,9 @@ package org.cryptomator.hub.entities; import io.agroal.api.AgroalDataSource; -import io.quarkus.panache.common.Parameters; import io.quarkus.test.TestTransaction; import io.quarkus.test.junit.QuarkusTest; import jakarta.inject.Inject; -import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceException; import org.hibernate.exception.ConstraintViolationException; import org.junit.jupiter.api.Assertions; @@ -14,7 +12,6 @@ import java.sql.SQLException; import java.time.Instant; -import java.util.List; import java.util.UUID; @QuarkusTest @@ -24,69 +21,32 @@ public class EntityIntegrationTest { @Inject AgroalDataSource dataSource; - @Inject - EntityManager entityManager; - @Test @TestTransaction - @DisplayName("Removing a Device cascades to Access") - public void removingDeviceCascadesToAccess() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { + @DisplayName("Removing a User cascades to Access") + public void removingUserCascadesToAccess() throws SQLException { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { // test data will be removed via @TestTransaction s.execute(""" - INSERT INTO "device" ("id", "owner_id", "name", "publickey", "creation_time") - VALUES ('device999', 'user1', 'Computer 999', 'publickey999', '2020-02-20 20:20:20'); - INSERT INTO "access_token" ("device_id", "vault_id", "jwe") VALUES ('device999', '7E57C0DE-0000-4000-8000-000100001111', 'jwe4'); + INSERT INTO "authority" ("id", "type", "name") VALUES ('user999', 'USER', 'User 999'); + INSERT INTO "user_details" ("id") VALUES ('user999'); + INSERT INTO "access_token" ("user_id", "vault_id", "vault_masterkey") VALUES ('user999', '7E57C0DE-0000-4000-8000-000100001111', 'jwe.jwe.jwe.vault1.user999'); """); } - var deleted = Device.deleteById("device999"); - var matchAfter = AccessToken.findAll().stream().anyMatch(a -> "device999".equals(a.device.id)); + var deleted = User.deleteById("user999"); + var matchAfter = AccessToken.findAll().stream().anyMatch(a -> "user999".equals(a.user.id)); Assertions.assertTrue(deleted); Assertions.assertFalse(matchAfter); } @Test @TestTransaction - @DisplayName("User's device names need to be unique") - public void testAddNonUniqueDeviceName() { - Device existingDevice = Device.findById("device1"); - Device conflictingDevice = new Device(); - conflictingDevice.id = "deviceX"; - conflictingDevice.name = existingDevice.name; - conflictingDevice.owner = existingDevice.owner; - conflictingDevice.publickey = "XYZ"; - conflictingDevice.creationTime = Instant.parse("2020-02-20T20:20:20Z"); - - PersistenceException thrown = Assertions.assertThrows(PersistenceException.class, conflictingDevice::persistAndFlush); - Assertions.assertInstanceOf(ConstraintViolationException.class, thrown); - } - - @Test - @TestTransaction - @DisplayName("Retrieve the correct token when a device has access to multiple vaults") - public void testGetCorrectTokenForDeviceWithAcessToMultipleVaults() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - // test data will be removed via @TestTransaction - s.execute(""" - INSERT INTO "device" ("id", "owner_id", "name", "publickey", "creation_time") - VALUES ('device999', 'user1', 'Computer 999', 'publickey999', '2020-02-20 20:20:20'); - INSERT INTO "access_token" ("device_id", "vault_id", "jwe") VALUES ('device999', '7E57C0DE-0000-4000-8000-000100001111', 'jwe4'); - INSERT INTO "access_token" ("device_id", "vault_id", "jwe") VALUES ('device999', '7E57C0DE-0000-4000-8000-000100002222', 'jwe5'); - """); - } - - List tokens = AccessToken - .find("#AccessToken.get", Parameters.with("deviceId", "device999") - .and("vaultId", UUID.fromString("7E57C0DE-0000-4000-8000-000100001111")) - .and("userId", "user1")) - .stream().toList(); - - var token = tokens.get(0); - - Assertions.assertEquals(1, tokens.size()); + @DisplayName("Retrieve the correct token when a user has access to multiple vaults") + public void testGetCorrectTokenForDeviceWithAcessToMultipleVaults() { + var token = AccessToken.unlock(UUID.fromString("7E57C0DE-0000-4000-8000-000100001111"), "user1"); Assertions.assertEquals(UUID.fromString("7E57C0DE-0000-4000-8000-000100001111"), token.vault.id); - Assertions.assertEquals("device999", token.device.id); - Assertions.assertEquals("jwe4", token.jwe); + Assertions.assertEquals("user1", token.user.id); + Assertions.assertEquals("jwe.jwe.jwe.vault1.user1", token.vaultKey); } } \ No newline at end of file diff --git a/backend/src/test/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProviderTest.java b/backend/src/test/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProviderTest.java deleted file mode 100644 index 12d4187af..000000000 --- a/backend/src/test/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProviderTest.java +++ /dev/null @@ -1,193 +0,0 @@ -package org.cryptomator.hub.filters; - -import com.auth0.jwt.JWT; -import com.auth0.jwt.JWTVerifier; -import com.auth0.jwt.algorithms.Algorithm; -import com.auth0.jwt.interfaces.DecodedJWT; -import io.quarkus.test.junit.QuarkusTest; -import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.core.MultivaluedHashMap; -import jakarta.ws.rs.core.UriInfo; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneId; -import java.time.temporal.ChronoUnit; - -@QuarkusTest -class VaultAdminOnlyFilterProviderTest { - - private static final String NO_VAULT_ID_TOKEN = "eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCJ9.e30.vT0jNCotwtzr37JNM_C6uZFCw3GvVjcikn-CVrDociILPiXBXA8i7dWFwBnUQkDBcFbouh-eUB_wEWgqe9WTG2rT66_c1G2LZUQcCsKdWJdTyK4ZxLXLYOYhNOHtqShI"; - private static final String INVALID_VAULT_ID_TOKEN = "eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCIsInZhdWx0SWQiOjI1fQ.e30.YxqmX5xeOviP9WldQV870zhPEF4PRaZrW0TaoWzm4lvkEmacIUt3OIoH0grAeh_gtJNRg4WfnqFNTgUx40-yDOtBLzyoeubfrMgb0-agN1898Mbr4ZhD1xqor0lBDrmc"; - private static final String NO_IAT_TOKEN_VAULT_2 = "eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCIsInZhdWx0SWQiOiI3RTU3QzBERS0wMDAwLTQwMDAtODAwMC0wMDAxMDAwMDIyMjIifQ.e30.etWcY66h7FAaiBo8SY6t1CLP4ivrYj8ld5OhJn1y3tZ71-EhedIryaDt8nty0DGsm6OFlKd0gUaz_OvU1TuJHArbzLg7uX3Ss-2-v4Kjx7K4E-UEqc4MMP0oCJa0g2re"; - private VaultAdminOnlyFilterProvider vaultAdminOnlyFilterProvider; - private ContainerRequestContext context; - private UriInfo uriInfo; - - @BeforeEach - void setUp() { - vaultAdminOnlyFilterProvider = Mockito.spy(new VaultAdminOnlyFilterProvider()); - context = Mockito.mock(ContainerRequestContext.class); - uriInfo = Mockito.mock(UriInfo.class); - } - - @Nested - @DisplayName("Test JWT verification") - public class TestJWTVerification { - - private static final String PUBLIC_KEY_VAULT_2 = "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEC1uWSXj2czCDwMTLWV5BFmwxdM6PX9p+Pk9Yf9rIf374m5XP1U8q79dBhLSIuaojsvOT39UUcPJROSD1FqYLued0rXiooIii1D3jaW6pmGVJFhodzC31cy5sfOYotrzF"; - - private static final DecodedJWT VALID_VAULT2 = JWT.decode(VaultAdminOnlyFilterProviderTestConstants.VALID_TOKEN_VAULT_2); - - @Test - @DisplayName("validate future IAT in leeway vaultAdminAuthorizationJWT") - public void testFutureIATInLeewayVaultAdminAuthorizationJWT() { - vaultAdminOnlyFilterProviderVerifierFor(VaultAdminOnlyFilterProviderTestConstants.NOW.plus(VaultAdminOnlyFilterProvider.REQUEST_LEEWAY_IN_SECONDS - 1, ChronoUnit.SECONDS)); - vaultAdminOnlyFilterProvider.verify(verifier(), VALID_VAULT2); - } - - @Test - @DisplayName("validate after IAT in leeway vaultAdminAuthorizationJWT") - public void testAfterIATInLeewayVaultAdminAuthorizationJWT() { - vaultAdminOnlyFilterProviderVerifierFor(VaultAdminOnlyFilterProviderTestConstants.NOW.minus(VaultAdminOnlyFilterProvider.REQUEST_LEEWAY_IN_SECONDS - 1, ChronoUnit.SECONDS)); - vaultAdminOnlyFilterProvider.verify(verifier(), VALID_VAULT2); - } - - @Test - @DisplayName("validate future IAT out leeway vaultAdminAuthorizationJWT") - public void testFutureIATOutLeewayVaultAdminAuthorizationJWT() { - vaultAdminOnlyFilterProviderVerifierFor(VaultAdminOnlyFilterProviderTestConstants.NOW.plus(VaultAdminOnlyFilterProvider.REQUEST_LEEWAY_IN_SECONDS + 1, ChronoUnit.SECONDS)); - var verifier = verifier(); - Assertions.assertThrows(VaultAdminTokenIAPNotValidException.class, () -> vaultAdminOnlyFilterProvider.verify(verifier, VALID_VAULT2)); - } - - @Test - @DisplayName("validate after IAT out leeway vaultAdminAuthorizationJWT") - public void testAfterIATOutLeewayAdminAuthorizationJWT() { - vaultAdminOnlyFilterProviderVerifierFor(VaultAdminOnlyFilterProviderTestConstants.NOW.minus(VaultAdminOnlyFilterProvider.REQUEST_LEEWAY_IN_SECONDS + 1, ChronoUnit.SECONDS)); - var verifier = verifier(); - Assertions.assertThrows(VaultAdminTokenIAPNotValidException.class, () -> vaultAdminOnlyFilterProvider.verify(verifier, VALID_VAULT2)); - } - - @Test - @DisplayName("validate no IAT in vaultAdminAuthorizationJWT") - public void testMalformedVaultAdminAuthorizationJWTNoDates() { - var verifier = verifier(); - var jwt = JWT.decode(NO_IAT_TOKEN_VAULT_2); - Assertions.assertThrows(VaultAdminValidationFailedException.class, () -> vaultAdminOnlyFilterProvider.verify(verifier, jwt)); - } - - private com.auth0.jwt.interfaces.JWTVerifier verifier() { - return vaultAdminOnlyFilterProvider.buildVerifier(Algorithm.ECDSA384(VaultAdminOnlyFilterProvider.decodePublicKey(PUBLIC_KEY_VAULT_2), null)); - } - - private void vaultAdminOnlyFilterProviderVerifierFor(Instant instant) { - // Decorate verifier to use fixed time - Mockito.doAnswer(invocationOnMock -> { - Algorithm algorithm = invocationOnMock.getArgument(0); - var verifier = (JWTVerifier.BaseVerification) vaultAdminOnlyFilterProvider.verification(algorithm, instant); - return verifier.build(Clock.fixed(instant, ZoneId.of("UTC"))); - }).when(vaultAdminOnlyFilterProvider).buildVerifier(Mockito.any()); - } - } - - @Nested - @DisplayName("Test vaultId qurey parameter") - public class TestVaultIdQueryParameter { - - @Test - @DisplayName("validate valid vaultId in query") - public void testGetValidVaultIdQueryParameter() { - var pathParams = new MultivaluedHashMap(); - pathParams.add(VaultAdminOnlyFilterProvider.VAULT_ID, "7E57C0DE-0000-4000-8000-000100002222"); - - Mockito.when(context.getUriInfo()).thenReturn(uriInfo); - Mockito.when(context.getUriInfo().getPathParameters()).thenReturn(pathParams); - - String result = vaultAdminOnlyFilterProvider.getVaultIdQueryParameter(context); - Assertions.assertEquals("7E57C0DE-0000-4000-8000-000100002222", result); - } - - @Test - @DisplayName("validate no vaultId in query") - public void testNoVaultIdQueryParameter() { - var pathParams = new MultivaluedHashMap(); - - Mockito.when(context.getUriInfo()).thenReturn(uriInfo); - Mockito.when(context.getUriInfo().getPathParameters()).thenReturn(pathParams); - - Assertions.assertThrows(VaultAdminValidationFailedException.class, () -> vaultAdminOnlyFilterProvider.getVaultIdQueryParameter(context)); - } - - @Test - @DisplayName("validate multiple vaultId in query") - public void testMultipleVaultIdQueryParameter() { - var pathParams = new MultivaluedHashMap(); - pathParams.add(VaultAdminOnlyFilterProvider.VAULT_ID, "7E57C0DE-0000-4000-8000-000100002222"); - pathParams.add(VaultAdminOnlyFilterProvider.VAULT_ID, "7E57C0DE-0000-4000-8000-000100003333"); - - Mockito.when(context.getUriInfo()).thenReturn(uriInfo); - Mockito.when(context.getUriInfo().getPathParameters()).thenReturn(pathParams); - - Assertions.assertThrows(VaultAdminValidationFailedException.class, () -> vaultAdminOnlyFilterProvider.getVaultIdQueryParameter(context)); - } - } - - @Nested - @DisplayName("Test vaultAdminAuthorizationJWT decoding") - public class TestVaultAdminAuthorizationJWTDecoding { - @Test - @DisplayName("validate valid VAULT_ADMIN_AUTHORIZATION") - public void testValidVaultAdminAuthorizationJWTProvided() { - Mockito.when(context.getHeaderString(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION)).thenReturn(VaultAdminOnlyFilterProviderTestConstants.VALID_TOKEN_VAULT_2); - DecodedJWT result = vaultAdminOnlyFilterProvider.getUnverifiedvaultAdminAuthorizationJWT(context); - Assertions.assertEquals(JWT.decode(VaultAdminOnlyFilterProviderTestConstants.VALID_TOKEN_VAULT_2).getHeader(), result.getHeader()); - Assertions.assertEquals(JWT.decode(VaultAdminOnlyFilterProviderTestConstants.VALID_TOKEN_VAULT_2).getPayload(), result.getPayload()); - } - - @Test - @DisplayName("validate no vaultAdminAuthorizationJWT") - public void testNoVaultAdminAuthorizationJWTProvided() { - Assertions.assertThrows(VaultAdminNotProvidedException.class, () -> vaultAdminOnlyFilterProvider.getUnverifiedvaultAdminAuthorizationJWT(context)); - } - - @Test - @DisplayName("validate malformed vaultAdminAuthorizationJWT") - public void testMalformedVaultAdminAuthorizationJWT() { - Mockito.when(context.getHeaderString(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION)).thenReturn(VaultAdminOnlyFilterProviderTestConstants.MALFORMED_TOKEN); - Assertions.assertThrows(VaultAdminValidationFailedException.class, () -> vaultAdminOnlyFilterProvider.getUnverifiedvaultAdminAuthorizationJWT(context)); - } - } - - @Nested - @DisplayName("Test VAULT_ADMIN_AUTHORIZATION header extraction") - public class TestVaultAdminAuthorizationJWTHeaderExtraction { - - @Test - @DisplayName("validate valid VAULT_ADMIN_AUTHORIZATION leads to valid vaultId") - public void testValidVaultAdminAuthorizationJWTLeadsToValidVaultId() { - String result = vaultAdminOnlyFilterProvider.getUnverifiedVaultId(JWT.decode(VaultAdminOnlyFilterProviderTestConstants.VALID_TOKEN_VAULT_2)); - Assertions.assertEquals("7E57C0DE-0000-4000-8000-000100002222", result); - } - - @Test - @DisplayName("validate no vaultId in VAULT_ADMIN_AUTHORIZATION") - public void testNoVaultIdInJWT() { - var jwt = JWT.decode(NO_VAULT_ID_TOKEN); - Assertions.assertThrows(VaultAdminValidationFailedException.class, () -> vaultAdminOnlyFilterProvider.getUnverifiedVaultId(jwt)); - } - - @Test - @DisplayName("validate invalid vaultId in VAULT_ADMIN_AUTHORIZATION") - public void testInvalidVaultIdInJWT() { - var jwt = JWT.decode(INVALID_VAULT_ID_TOKEN); - Assertions.assertThrows(VaultAdminValidationFailedException.class, () -> vaultAdminOnlyFilterProvider.getUnverifiedVaultId(jwt)); - } - } -} \ No newline at end of file diff --git a/backend/src/test/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProviderTestConstants.java b/backend/src/test/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProviderTestConstants.java deleted file mode 100644 index 9dfdf83c6..000000000 --- a/backend/src/test/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProviderTestConstants.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.cryptomator.hub.filters; - -import java.time.Instant; - -final class VaultAdminOnlyFilterProviderTestConstants { - - // { "alg": "ES384", "typ": "JWT", "vaultId": "7E57C0DE-0000-4000-8000-000100002222" } { "iat": 1516239015 (2018-01-18T01:30:15) } - static final String VALID_TOKEN_VAULT_2 = "eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCIsInZhdWx0SWQiOiI3RTU3QzBERS0wMDAwLTQwMDAtODAwMC0wMDAxMDAwMDIyMjIifQ.eyJpYXQiOjE1MTYyMzkwMTV9.x3-JltFRYrwC6fNBgtvCHyIh8HzmcS190GVSbKzhLROeIyYpvvWo9PH_nVHa_8p6xQoMrwf7-H5gQYVm3EhtHWO_2CZro55zdzFkLThU26ql6yWtGPNroTmOyUT1MSQs"; - - // { "alg": "ES384", "typ": "JWT", "vaultId": "7E57C0DE-0000-4000-8000-000100003000" } { "iat": 1516239015 (2018-01-18T01:30:15) } - static final String VALID_TOKEN_VAULT_3000 = "eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCIsInZhdWx0SWQiOiI3RTU3QzBERS0wMDAwLTQwMDAtODAwMC0wMDAxMDAwMDMwMDAifQ.eyJpYXQiOjE1MTYyMzkwMTV9.MCI388EG6LAXuRLVm6_YFEP-Up8bYI2SBvCtIv3azrPtmNbidR5KxtSVoV_W3iFsG8AUj4G7JLxT8F-b4Dw1i3VBhPMVl4GlC_AN89yvp5SPgtfYmIUdHWvcugahayHh"; - - // { "alg": "ES384", "typ": "JWT", "vaultId": "7E57C0DE-0000-4000-8000-000100002222" } { "iat": 1516239015 (2018-01-18T01:30:15) } but signed with key of vault1 - static final String INVALID_SIGNATURE_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsInZhdWx0SWQiOiI3RTU3QzBERS0wMDAwLTQwMDAtODAwMC0wMDAxMDAwMDIyMjIifQ.e30.9_5pMhgkn9iyOG01T82hB00tHEELwMX0BGIc2_DwzZSizJYNz312B5xWkI1TOwzteEpWO2ivdki3NfgJkRsNBOJ02H5QJ8Zg4qT5lCbWySdZpMeSODTjHRuN5lErwAR2"; - static final String MALFORMED_TOKEN = "hello world"; - static final Instant NOW = Instant.ofEpochSecond(1516239015); // 2018-01-18T01:30:15 - -} diff --git a/backend/src/test/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProviderTestIT.java b/backend/src/test/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProviderTestIT.java deleted file mode 100644 index f6820abed..000000000 --- a/backend/src/test/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProviderTestIT.java +++ /dev/null @@ -1,128 +0,0 @@ -package org.cryptomator.hub.filters; - -import com.auth0.jwt.JWTVerifier; -import com.auth0.jwt.algorithms.Algorithm; -import io.agroal.api.AgroalDataSource; -import io.quarkus.test.junit.QuarkusTest; -import jakarta.inject.Inject; -import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.core.MultivaluedHashMap; -import jakarta.ws.rs.core.UriInfo; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.sql.SQLException; -import java.time.Clock; -import java.time.ZoneId; - -@QuarkusTest -class VaultAdminOnlyFilterProviderTestIT { - - private VaultAdminOnlyFilterProvider vaultAdminOnlyFilterProvider; - private ContainerRequestContext context; - private UriInfo uriInfo; - - @Inject - AgroalDataSource dataSource; - - @BeforeEach - void setUp() { - vaultAdminOnlyFilterProvider = Mockito.spy(new VaultAdminOnlyFilterProvider()); - context = Mockito.mock(ContainerRequestContext.class); - uriInfo = Mockito.mock(UriInfo.class); - - // Decorate verifier to use fixed time - Mockito.doAnswer(invocationOnMock -> { - Algorithm algorithm = invocationOnMock.getArgument(0); - var verifier = (JWTVerifier.BaseVerification) vaultAdminOnlyFilterProvider.verification(algorithm, VaultAdminOnlyFilterProviderTestConstants.NOW); - return verifier.build(Clock.fixed(VaultAdminOnlyFilterProviderTestConstants.NOW, ZoneId.of("UTC"))); - }).when(vaultAdminOnlyFilterProvider).buildVerifier(Mockito.any()); - } - - @Test - @DisplayName("validate valid vaultAdminAuthorizationJWT header") - public void testValidVaultAdminAuthorizationJWTHeader() { - var pathParams = new MultivaluedHashMap(); - pathParams.add(VaultAdminOnlyFilterProvider.VAULT_ID, "7E57C0DE-0000-4000-8000-000100002222"); - - Mockito.when(context.getHeaderString(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION)).thenReturn(VaultAdminOnlyFilterProviderTestConstants.VALID_TOKEN_VAULT_2); - Mockito.when(context.getUriInfo()).thenReturn(uriInfo); - Mockito.when(context.getUriInfo().getPathParameters()).thenReturn(pathParams); - - vaultAdminOnlyFilterProvider.filter(context); - } - - @Test - @DisplayName("validate no vaultAdminAuthorizationJWT header provided") - public void testNoVaultAdminAuthorizationJWTHeader() { - var pathParams = new MultivaluedHashMap(); - pathParams.add(VaultAdminOnlyFilterProvider.VAULT_ID, "7E57C0DE-0000-4000-8000-000100002222"); - - Mockito.when(context.getUriInfo()).thenReturn(uriInfo); - Mockito.when(context.getUriInfo().getPathParameters()).thenReturn(pathParams); - - Assertions.assertThrows(VaultAdminNotProvidedException.class, () -> vaultAdminOnlyFilterProvider.filter(context)); - } - - @Test - @DisplayName("validate other path-param provided") - public void testOtherPathParamProvided() { - var pathParams = new MultivaluedHashMap(); - pathParams.add(VaultAdminOnlyFilterProvider.VAULT_ID, "7E57C0DE-0000-4000-8000-000100003000"); - - Mockito.when(context.getHeaderString(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION)).thenReturn(VaultAdminOnlyFilterProviderTestConstants.VALID_TOKEN_VAULT_2); - Mockito.when(context.getUriInfo()).thenReturn(uriInfo); - Mockito.when(context.getUriInfo().getPathParameters()).thenReturn(pathParams); - - Assertions.assertThrows(VaultAdminValidationFailedException.class, () -> vaultAdminOnlyFilterProvider.filter(context)); - } - - @Test - @DisplayName("validate vaultAdminAuthorizationJWT header signed by other key") - public void testOtherKeyVaultAdminAuthorizationJWTHeader() { - var pathParams = new MultivaluedHashMap(); - pathParams.add(VaultAdminOnlyFilterProvider.VAULT_ID, "7E57C0DE-0000-4000-8000-000100002222"); - - Mockito.when(context.getHeaderString(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION)).thenReturn(VaultAdminOnlyFilterProviderTestConstants.INVALID_SIGNATURE_TOKEN); - Mockito.when(context.getUriInfo()).thenReturn(uriInfo); - Mockito.when(context.getUriInfo().getPathParameters()).thenReturn(pathParams); - - Assertions.assertThrows(VaultAdminValidationFailedException.class, () -> vaultAdminOnlyFilterProvider.filter(context)); - } - - @Test - @DisplayName("validate malformed vaultAdminAuthorizationJWT header") - public void testMalformedVaultAdminAuthorizationJWTHeader() { - var pathParams = new MultivaluedHashMap(); - pathParams.add(VaultAdminOnlyFilterProvider.VAULT_ID, "7E57C0DE-0000-4000-8000-000100002222"); - - Mockito.when(context.getHeaderString(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION)).thenReturn(VaultAdminOnlyFilterProviderTestConstants.MALFORMED_TOKEN); - Mockito.when(context.getUriInfo()).thenReturn(uriInfo); - Mockito.when(context.getUriInfo().getPathParameters()).thenReturn(pathParams); - - Assertions.assertThrows(VaultAdminValidationFailedException.class, () -> vaultAdminOnlyFilterProvider.filter(context)); - } - - @Test - @DisplayName("validate malformed key in database") - public void testMalformedKeyInDatabase() throws SQLException { - var pathParams = new MultivaluedHashMap(); - pathParams.add(VaultAdminOnlyFilterProvider.VAULT_ID, "7E57C0DE-0000-4000-8000-000100003000"); - - Mockito.when(context.getHeaderString(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION)).thenReturn(VaultAdminOnlyFilterProviderTestConstants.VALID_TOKEN_VAULT_3000); - Mockito.when(context.getUriInfo()).thenReturn(uriInfo); - Mockito.when(context.getUriInfo().getPathParameters()).thenReturn(pathParams); - - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - INSERT INTO "vault" ("id", "name", "description", "creation_time", "salt", "iterations", "masterkey", "auth_pubkey", "auth_prvkey") - VALUES ('7E57C0DE-0000-4000-8000-000100003000', 'Vault 1000', 'This is a testvault.', '2020-02-20 20:20:20', 'salt3000', 'iterations3000', 'masterkey3000', 'pubkey', 'prvkey') - """); - - Assertions.assertThrows(VaultAdminValidationFailedException.class, () -> vaultAdminOnlyFilterProvider.filter(context)); - } - } -} \ No newline at end of file diff --git a/backend/src/test/java/org/cryptomator/hub/filters/VaultRoleFilterTest.java b/backend/src/test/java/org/cryptomator/hub/filters/VaultRoleFilterTest.java new file mode 100644 index 000000000..a190985b6 --- /dev/null +++ b/backend/src/test/java/org/cryptomator/hub/filters/VaultRoleFilterTest.java @@ -0,0 +1,185 @@ +package org.cryptomator.hub.filters; + +import io.quarkus.test.junit.QuarkusTest; +import jakarta.annotation.Nullable; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotAuthorizedException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ResourceInfo; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.UriInfo; +import org.cryptomator.hub.entities.VaultAccess; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Method; +import java.util.Map; + +@QuarkusTest +public class VaultRoleFilterTest { + + private final ResourceInfo resourceInfo = Mockito.mock(ResourceInfo.class); + private final UriInfo uriInfo = Mockito.mock(UriInfo.class); + private final ContainerRequestContext context = Mockito.mock(ContainerRequestContext.class); + private final JsonWebToken jwt = Mockito.mock(JsonWebToken.class); + private final VaultRoleFilter filter = new VaultRoleFilter(); + + @BeforeEach + public void setup() { + filter.resourceInfo = resourceInfo; + filter.jwt = jwt; + + Mockito.doReturn(uriInfo).when(context).getUriInfo(); + } + + @Test + @DisplayName("error 403 if annotated resource has no vaultId path param") + public void testFilterWithMissingVaultId() throws NoSuchMethodException { + Mockito.doReturn(VaultRoleFilterTest.class.getMethod("allowMember")).when(resourceInfo).getResourceMethod(); + Mockito.doReturn(new MultivaluedHashMap<>()).when(uriInfo).getPathParameters(); + + Assertions.assertThrows(ForbiddenException.class, () -> filter.filter(context)); + } + + @Test + @DisplayName("error 401 if JWT is missing") + public void testFilterWithMissingJWT() throws NoSuchMethodException { + Mockito.doReturn(VaultRoleFilterTest.class.getMethod("allowMember")).when(resourceInfo).getResourceMethod(); + Mockito.doReturn(new MultivaluedHashMap<>(Map.of(VaultRole.DEFAULT_VAULT_ID_PARAM, "7E57C0DE-0000-4000-8000-000100001111"))).when(uriInfo).getPathParameters(); + + Assertions.assertThrows(NotAuthorizedException.class, () -> filter.filter(context)); + } + + @Test + @DisplayName("error 403 if user2 tries to access 7E57C0DE-0000-4000-8000-000100001111") + public void testFilterWithInsufficientPrivileges() throws NoSuchMethodException { + Mockito.doReturn(VaultRoleFilterTest.class.getMethod("allowOwner")).when(resourceInfo).getResourceMethod(); + Mockito.doReturn(new MultivaluedHashMap<>(Map.of(VaultRole.DEFAULT_VAULT_ID_PARAM, "7E57C0DE-0000-4000-8000-000100001111"))).when(uriInfo).getPathParameters(); + Mockito.doReturn("user2").when(jwt).getSubject(); + + var e = Assertions.assertThrows(ForbiddenException.class, () -> filter.filter(context)); + + Assertions.assertEquals("Vault role required: OWNER", e.getMessage()); + } + + @Test + @DisplayName("pass if user1 tries to access 7E57C0DE-0000-4000-8000-000100001111 (user1 is OWNER of vault)") + public void testFilterSuccess1() throws NoSuchMethodException { + Mockito.doReturn(VaultRoleFilterTest.class.getMethod("allowOwner")).when(resourceInfo).getResourceMethod(); + Mockito.doReturn(new MultivaluedHashMap<>(Map.of(VaultRole.DEFAULT_VAULT_ID_PARAM, "7E57C0DE-0000-4000-8000-000100001111"))).when(uriInfo).getPathParameters(); + Mockito.doReturn("user1").when(jwt).getSubject(); + + Assertions.assertDoesNotThrow(() -> filter.filter(context)); + } + + @Test + @DisplayName("pass if user2 tries to access 7E57C0DE-0000-4000-8000-000100002222 (user2 is member of group2, which is OWNER of the vault)") + public void testFilterSuccess2() throws NoSuchMethodException { + Mockito.doReturn(VaultRoleFilterTest.class.getMethod("allowOwner")).when(resourceInfo).getResourceMethod(); + Mockito.doReturn(new MultivaluedHashMap<>(Map.of(VaultRole.DEFAULT_VAULT_ID_PARAM, "7E57C0DE-0000-4000-8000-000100002222"))).when(uriInfo).getPathParameters(); + Mockito.doReturn("user2").when(jwt).getSubject(); + + Assertions.assertDoesNotThrow(() -> filter.filter(context)); + } + + @Nested + @DisplayName("when attempting to access archived vault") + public class OnArchivedVault { + + @BeforeEach + public void setup() { + Mockito.doReturn(new MultivaluedHashMap<>(Map.of(VaultRole.DEFAULT_VAULT_ID_PARAM, "7E57C0DE-0000-4000-8000-00010000AAAA"))).when(uriInfo).getPathParameters(); + } + + @Test + @DisplayName("pass if user1 tries to access 7E57C0DE-0000-4000-8000-00010000AAAA (user1 is OWNER of vault)") + public void testFilterSuccess() throws NoSuchMethodException { + Mockito.doReturn(VaultRoleFilterTest.class.getMethod("allowOwner")).when(resourceInfo).getResourceMethod(); + Mockito.doReturn("user1").when(jwt).getSubject(); + + Assertions.assertDoesNotThrow(() -> filter.filter(context)); + } + + @Test + @DisplayName("error 403 if user2 tries to access 7E57C0DE-0000-4000-8000-00010000AAAA") + public void testFilterWithInsufficientPrivileges() throws NoSuchMethodException { + Mockito.doReturn(VaultRoleFilterTest.class.getMethod("allowOwner")).when(resourceInfo).getResourceMethod(); + Mockito.doReturn("user2").when(jwt).getSubject(); + + var e = Assertions.assertThrows(ForbiddenException.class, () -> filter.filter(context)); + + Assertions.assertEquals("Vault role required: OWNER", e.getMessage()); + } + + } + + @Nested + @DisplayName("when attempting to access non-existing vault") + public class OnMissingVault { + + @BeforeEach + public void setup() { + Mockito.doReturn(new MultivaluedHashMap<>(Map.of(VaultRole.DEFAULT_VAULT_ID_PARAM, "7E57C0DE-0000-4000-8000-BADBADBADBAD"))).when(uriInfo).getPathParameters(); + Mockito.doReturn("user1").when(jwt).getSubject(); + } + + @Test + @DisplayName("error 403 if annotated with @VaultRole(onMissingVault = OnMissingVault.FORBIDDEN)") + public void testForbidden() throws NoSuchMethodException { + Mockito.doReturn(NonExistingVault.class.getMethod("forbidden")).when(resourceInfo).getResourceMethod(); + + var e = Assertions.assertThrows(ForbiddenException.class, () -> filter.filter(context)); + + Assertions.assertEquals("Vault role required: OWNER", e.getMessage()); + } + + @Test + @DisplayName("error 404 if annotated with @VaultRole(onMissingVault = OnMissingVault.NOT_FOUND)") + public void testNotFound() throws NoSuchMethodException { + Mockito.doReturn(NonExistingVault.class.getMethod("notFound")).when(resourceInfo).getResourceMethod(); + + var e = Assertions.assertThrows(NotFoundException.class, () -> filter.filter(context)); + + Assertions.assertEquals("Vault not found", e.getMessage()); + } + + @Test + @DisplayName("pass if annotated with @VaultRole(onMissingVault = OnMissingVault.PASS)") + public void testPass() throws NoSuchMethodException { + Mockito.doReturn(NonExistingVault.class.getMethod("pass")).when(resourceInfo).getResourceMethod(); + + Assertions.assertDoesNotThrow(() -> filter.filter(context)); + } + + } + + /* + * "real" methods for testing below, as we can not mock Method.class without breaking Mockito + */ + + @VaultRole({VaultAccess.Role.MEMBER}) + public void allowMember() {} + + @VaultRole({VaultAccess.Role.OWNER}) + public void allowOwner() {} + + public static class NonExistingVault { + @VaultRole(value = {VaultAccess.Role.OWNER}, onMissingVault = VaultRole.OnMissingVault.FORBIDDEN) + public void forbidden() {} + + @VaultRole(value = {VaultAccess.Role.OWNER}, onMissingVault = VaultRole.OnMissingVault.NOT_FOUND) + public void notFound() {} + + @VaultRole(value = {VaultAccess.Role.OWNER}, onMissingVault = VaultRole.OnMissingVault.PASS) + public void pass() {} + } + + +} \ No newline at end of file diff --git a/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java b/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java index 5a162d7ce..005e32586 100644 --- a/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java +++ b/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java @@ -2,8 +2,12 @@ import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.interfaces.DecodedJWT; +import io.quarkus.arc.Arc; +import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.junit.mockito.InjectMock; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import jakarta.inject.Inject; import org.cryptomator.hub.entities.Settings; import org.hibernate.Session; import org.hibernate.query.Query; @@ -13,13 +17,28 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.mockito.MockedStatic; import org.mockito.Mockito; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicBoolean; @QuarkusTest public class LicenseHolderTest { + @Inject LicenseHolder holder; @Nested @@ -29,20 +48,25 @@ class TestPostConstruct { @InjectMock Session session; + @InjectMock LicenseValidator validator; + @InjectMock + RandomMinuteSleeper randomMinuteSleeper; + MockedStatic settingsClass; @BeforeEach - public void setup() { + public void setup() throws InterruptedException { Query mockQuery = Mockito.mock(Query.class); Mockito.doNothing().when(session).persist(Mockito.any()); Mockito.when(session.createQuery(Mockito.anyString())).thenReturn(mockQuery); Mockito.when(mockQuery.getSingleResult()).thenReturn(0l); + Mockito.doNothing().when(randomMinuteSleeper).sleep(); + + Arc.container().instance(LicenseHolder.class).destroy(); - validator = Mockito.mock(LicenseValidator.class); settingsClass = Mockito.mockStatic(Settings.class); - holder = new LicenseHolder(validator); } @AfterEach @@ -62,14 +86,18 @@ public void testValidDBTokenSet() { holder.init(); - Mockito.verify(validator, Mockito.times(1)).validate("token", "42"); + // init implicitly called due to @PostConstruct which increases the times to verify by 1 + // See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information + Mockito.verify(validator, Mockito.times(2)).validate("token", "42"); Assertions.assertEquals(decodedJWT, holder.get()); } @Test @DisplayName("If database token is invalid, do net set it in license holder and nullify db entry") public void testDBTokenOnFailedValidationNotSet() { - Mockito.when(validator.validate(Mockito.anyString(), Mockito.anyString())).thenThrow(JWTVerificationException.class); + Mockito.when(validator.validate(Mockito.anyString(), Mockito.anyString())).thenAnswer(invocationOnMock -> { + throw new JWTVerificationException(""); + }); Settings settingsMock = new Settings(); settingsMock.licenseKey = "token"; settingsMock.hubId = "42"; @@ -78,7 +106,7 @@ public void testDBTokenOnFailedValidationNotSet() { holder.init(); Mockito.verify(validator, Mockito.times(1)).validate("token", "42"); - Mockito.verify(session, Mockito.times(1)).persist(Mockito.any()); + Mockito.verify(session, Mockito.times(1)).persist(Mockito.eq(settingsMock)); Assertions.assertNull(holder.get()); } @@ -87,6 +115,7 @@ public void testDBTokenOnFailedValidationNotSet() { public void testNullDBTokenNotSet() { Settings settingsEntity = Mockito.mock(Settings.class); settingsClass.when(Settings::get).thenReturn(settingsEntity); + holder.init(); Mockito.verify(validator, Mockito.never()).validate(Mockito.anyString(), Mockito.anyString()); @@ -103,20 +132,25 @@ class TestSetter { @InjectMock Session session; + @InjectMock LicenseValidator validator; + @InjectMock + RandomMinuteSleeper randomMinuteSleeper; + MockedStatic settingsClass; @BeforeEach - public void setup() { + public void setup() throws InterruptedException { Query mockQuery = Mockito.mock(Query.class); Mockito.doNothing().when(session).persist(Mockito.any()); Mockito.when(session.createQuery(Mockito.anyString())).thenReturn(mockQuery); Mockito.when(mockQuery.getSingleResult()).thenReturn(0l); + Mockito.doNothing().when(randomMinuteSleeper).sleep(); + + Arc.container().instance(LicenseHolder.class).destroy(); - validator = Mockito.mock(LicenseValidator.class); settingsClass = Mockito.mockStatic(Settings.class); - holder = new LicenseHolder(validator); } @AfterEach @@ -136,7 +170,7 @@ public void testSetValidToken() { holder.set("token"); Mockito.verify(validator, Mockito.times(1)).validate("token", "42"); - Mockito.verify(session, Mockito.times(1)).persist(Mockito.any()); + Mockito.verify(session, Mockito.times(1)).persist(Mockito.eq(settingsMock)); Assertions.assertEquals("token", settingsMock.licenseKey); Assertions.assertEquals(decodedJWT, holder.get()); } @@ -144,7 +178,9 @@ public void testSetValidToken() { @Test @DisplayName("Setting an invalid token fails with exception") public void testSetInvalidToken() { - Mockito.when(validator.validate("token", "42")).thenThrow(JWTVerificationException.class); + Mockito.when(validator.validate("token", "42")).thenAnswer(invocationOnMock -> { + throw new JWTVerificationException(""); + }); Settings settingsMock = new Settings(); settingsMock.hubId = "42"; settingsClass.when(Settings::get).thenReturn(settingsMock); @@ -157,6 +193,359 @@ public void testSetInvalidToken() { } } + @Nested + @DisplayName("Testing refreshLicense() method of LicenseHolder") + class TestRefreshLicense { + + private final String refreshURL = "https://foo.bar.baz/"; + + private final HttpRequest refreshRequst = HttpRequest.newBuilder() // + .uri(URI.create(refreshURL)) // + .headers("Content-Type", "application/x-www-form-urlencoded") // + .POST(HttpRequest.BodyPublishers.ofString("token=token")) // + .build(); + + @InjectMock + Session session; + + @InjectMock + LicenseValidator validator; + + @InjectMock + RandomMinuteSleeper randomMinuteSleeper; + + MockedStatic settingsClass; + + @BeforeEach + public void setup() throws InterruptedException { + Query mockQuery = Mockito.mock(Query.class); + Mockito.doNothing().when(session).persist(Mockito.any()); + Mockito.when(session.createQuery(Mockito.anyString())).thenReturn(mockQuery); + Mockito.when(mockQuery.getSingleResult()).thenReturn(0l); + Mockito.doNothing().when(randomMinuteSleeper).sleep(); + + Arc.container().instance(LicenseHolder.class).destroy(); + + settingsClass = Mockito.mockStatic(Settings.class); + } + + @AfterEach + public void teardown() { + settingsClass.close(); + } + + @Test + @DisplayName("Refreshing a valid token validates and persists it to db") + public void testRefreshingExistingValidTokenInculdingRefreshURL() throws IOException, InterruptedException { + var existingJWT = Mockito.mock(DecodedJWT.class); + var receivedJWT = Mockito.mock(DecodedJWT.class); + Mockito.when(existingJWT.getToken()).thenReturn("token&foo=bar"); + Mockito.when(validator.validate("token", "42")).thenReturn(receivedJWT); + Mockito.when(validator.validate("token&foo=bar", "42")).thenReturn(existingJWT); + Settings settingsMock = new Settings(); + settingsMock.hubId = "42"; + settingsMock.licenseKey = "token&foo=bar"; + settingsClass.when(Settings::get).thenReturn(settingsMock); + + var refreshTokenContainingSpecialChars = HttpRequest.newBuilder() // + .uri(URI.create(refreshURL)) // + .headers("Content-Type", "application/x-www-form-urlencoded") // + .POST(HttpRequest.BodyPublishers.ofString("token=token%26foo%3Dbar")) // + .build(); + + var httpClient = Mockito.mock(HttpClient.class); + var response = Mockito.mock(HttpResponse.class); + Mockito.doAnswer(invocation -> { + HttpRequest httpRequest = invocation.getArgument(0); + Assertions.assertEquals(refreshTokenContainingSpecialChars, httpRequest); + Assertions.assertEquals("token=token%26foo%3Dbar", getResponseFromRequest(httpRequest)); + return response; + }).when(httpClient).send(Mockito.any(), Mockito.eq(HttpResponse.BodyHandlers.ofString())); + Mockito.when(response.body()).thenReturn("token"); + Mockito.when(response.statusCode()).thenReturn(200); + + holder.refreshLicense(refreshURL, existingJWT.getToken(), httpClient); + + Mockito.verify(validator, Mockito.times(1)).validate("token", "42"); + Mockito.verify(session, Mockito.times(1)).persist(Mockito.eq(settingsMock)); + Assertions.assertEquals(receivedJWT, holder.get()); + } + + @ParameterizedTest(name = "Refreshing a valid token but receiving \"{0}\" with status code does \"{1}\" not persists it to db") + @CsvSource(value = {"invalidToken,200", "'',200", "validToken,500"}) + public void testInvalidTokenReceivedLeadsToNoOp(String receivedToken, int receivedCode) throws IOException, InterruptedException { + var existingJWT = Mockito.mock(DecodedJWT.class); + var receivedJWT = Mockito.mock(DecodedJWT.class); + Mockito.when(existingJWT.getToken()).thenReturn("token"); + Mockito.when(validator.validate("token", "42")).thenReturn(existingJWT); + if (receivedToken.equals("validToken")) { + Mockito.when(validator.validate(receivedToken, "42")).thenReturn(receivedJWT); + } else { + Mockito.when(validator.validate(receivedToken, "42")).thenAnswer(invocationOnMock -> { + throw new JWTVerificationException(""); + }); + } + Settings settingsMock = new Settings(); + settingsMock.hubId = "42"; + settingsMock.licenseKey = "token"; + settingsClass.when(Settings::get).thenReturn(settingsMock); + + var httpClient = Mockito.mock(HttpClient.class); + var response = Mockito.mock(HttpResponse.class); + Mockito.doAnswer(invocation -> { + HttpRequest httpRequest = invocation.getArgument(0); + Assertions.assertEquals(refreshRequst, httpRequest); + Assertions.assertEquals("token=token", getResponseFromRequest(httpRequest)); + return response; + }).when(httpClient).send(Mockito.any(), Mockito.eq(HttpResponse.BodyHandlers.ofString())); + Mockito.when(response.body()).thenReturn(receivedToken); + Mockito.when(response.statusCode()).thenReturn(receivedCode); + + holder.refreshLicense(refreshURL, existingJWT.getToken(), httpClient); + + // init implicitly called due to @PostConstruct which increases the times to verify by 1 + // See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information + Mockito.verify(validator, Mockito.times(1)).validate("token", "42"); + Mockito.verify(session, Mockito.never()).persist(Mockito.any()); + Assertions.assertEquals(existingJWT, holder.get()); + } + + @Test + @DisplayName("Refreshing a valid token but IOException thrown does not persists it to db") + public void testCommunicationProblemLeadsToNoOp() throws IOException, InterruptedException { + var existingJWT = Mockito.mock(DecodedJWT.class); + Mockito.when(existingJWT.getToken()).thenReturn("token"); + Mockito.when(validator.validate("token", "42")).thenReturn(existingJWT); + Settings settingsMock = new Settings(); + settingsMock.hubId = "42"; + settingsMock.licenseKey = "token"; + settingsClass.when(Settings::get).thenReturn(settingsMock); + + var httpClient = Mockito.mock(HttpClient.class); + Mockito.doAnswer(invocation -> { + HttpRequest httpRequest = invocation.getArgument(0); + Assertions.assertEquals(refreshRequst, httpRequest); + throw new IOException("Problem during communication"); + }).when(httpClient).send(Mockito.any(), Mockito.eq(HttpResponse.BodyHandlers.ofString())); + + holder.refreshLicense(refreshURL, existingJWT.getToken(), httpClient); + + // init implicitly called due to @PostConstruct which increases the times to verify by 1 + // See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information + Mockito.verify(validator, Mockito.times(1)).validate("token", "42"); + Mockito.verify(session, Mockito.never()).persist(Mockito.any()); + Assertions.assertEquals(existingJWT, holder.get()); + } + + @Test + @DisplayName("Refreshing a valid token without refresh URL does not execute refreshLicense") + public void testNoOpExistingValidTokenExculdingRefreshURL() throws InterruptedException { + var existingJWT = Mockito.mock(DecodedJWT.class); + Mockito.when(validator.validate("token", "42")).thenReturn(existingJWT); + Mockito.when(validator.refreshUrl(existingJWT.getToken())).thenReturn(Optional.empty()); + Settings settingsMock = new Settings(); + settingsMock.hubId = "42"; + settingsMock.licenseKey = "token"; + settingsClass.when(Settings::get).thenReturn(settingsMock); + + holder.refreshLicenseScheduler(); + + // init implicitly called due to @PostConstruct which increases the times to verify by 1 + // See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information + Mockito.verify(validator, Mockito.times(1)).validate("token", "42"); + Mockito.verify(session, Mockito.never()).persist(Mockito.any()); + Assertions.assertEquals(existingJWT, holder.get()); + } + + private String getResponseFromRequest(HttpRequest httpRequest) { + return httpRequest.bodyPublisher().map(p -> { + var bodySubscriber = HttpResponse.BodySubscribers.ofString(StandardCharsets.UTF_8); + var flowSubscriber = new StringSubscriber(bodySubscriber); + p.subscribe(flowSubscriber); + return bodySubscriber.getBody().toCompletableFuture().join(); + }).get(); + } + + private record StringSubscriber(HttpResponse.BodySubscriber wrapped) implements Flow.Subscriber { + + @Override + public void onSubscribe(Flow.Subscription subscription) { + wrapped.onSubscribe(subscription); + } + + @Override + public void onNext(ByteBuffer item) { + wrapped.onNext(List.of(item)); + } + + @Override + public void onError(Throwable throwable) { + wrapped.onError(throwable); + } + + @Override + public void onComplete() { + wrapped.onComplete(); + } + } + } + + @Nested + @TestProfile(LicenseHolderInitPropsTest.ValidInitPropsInstanceTestProfile.class) + @DisplayName("Testing LicenseHolder methods using InitProps") + class LicenseHolderInitPropsTest { + + @InjectMock + Session session; + + @InjectMock + LicenseValidator validator; + + @InjectMock + RandomMinuteSleeper randomMinuteSleeper; + + MockedStatic settingsClass; + + public static class ValidInitPropsInstanceTestProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of("hub.initial-id", "42", "hub.initial-license", "token"); + } + } + + @BeforeEach + public void setup() throws InterruptedException { + Query mockQuery = Mockito.mock(Query.class); + Mockito.doNothing().when(session).persist(Mockito.any()); + Mockito.when(session.createQuery(Mockito.anyString())).thenReturn(mockQuery); + Mockito.when(mockQuery.getSingleResult()).thenReturn(0l); + Mockito.doNothing().when(randomMinuteSleeper).sleep(); + + Arc.container().instance(LicenseHolder.class).destroy(); + + settingsClass = Mockito.mockStatic(Settings.class); + } + + @AfterEach + public void teardown() { + settingsClass.close(); + } + + @Test + @DisplayName("If init token is valid, set it in license holder") + public void testValidInitTokenSet() { + var decodedJWT = Mockito.mock(DecodedJWT.class); + Mockito.when(validator.validate("token", "42")).thenReturn(decodedJWT); + Settings settingsMock = new Settings(); + settingsMock.hubId = "42"; + settingsClass.when(Settings::get).thenReturn(settingsMock); + + var newLicensePersisted = new AtomicBoolean(false); + Mockito.doAnswer(invocation -> { + Settings settings = invocation.getArgument(0); + if (settings.hubId.equals("42") && settings.licenseKey.equals("token")) { + newLicensePersisted.set(true); + } + return null; + }).when(session).persist(Mockito.any()); + + holder.init(); + + // init implicitly called due to @PostConstruct which increases the times to verify by 1 + // See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information + Mockito.verify(validator, Mockito.times(2)).validate("token", "42"); + Assertions.assertTrue(newLicensePersisted.get()); + Assertions.assertEquals(decodedJWT, holder.get()); + } + + @Test + @DisplayName("If init token is invalid and no token is set in db, do not modify db") + public void testInitTokenOnFailedValidationNotSet() { + Mockito.when(validator.validate("token", "42")).thenAnswer(invocationOnMock -> { + throw new JWTVerificationException(""); + }); + Settings settingsMock = new Settings(); + settingsMock.hubId = "42"; + settingsClass.when(Settings::get).thenReturn(settingsMock); + + holder.init(); + + // init implicitly called due to @PostConstruct which increases the times to verify by 1 + // See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information + Mockito.verify(validator, Mockito.times(2)).validate("token", "42"); + Mockito.verify(session, Mockito.never()).persist(Mockito.eq(settingsMock)); + Assertions.assertNull(holder.get()); + } + + @Test + @DisplayName("If token is set in DB, ignore valid init token") + public void testValidDBTokenIgnoresValidInitToken() { + var decodedJWT = Mockito.mock(DecodedJWT.class); + Mockito.when(validator.validate("token3000", "3000")).thenReturn(decodedJWT); + Settings settingsMock = new Settings(); + settingsMock.hubId = "3000"; + settingsMock.licenseKey = "token3000"; + settingsClass.when(Settings::get).thenReturn(settingsMock); + + holder.init(); + + // init implicitly called due to @PostConstruct which increases the times to verify by 1 + // See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information + Mockito.verify(validator, Mockito.times(2)).validate("token3000", "3000"); + Mockito.verify(session, Mockito.never()).persist(Mockito.any()); + Assertions.assertEquals(decodedJWT, holder.get()); + } + + @Test + @DisplayName("If token is set in DB, ignore invalid init token") + public void testValidDBTokenIgnoresInvalidInitToken() { + Mockito.when(validator.validate("token", "42")).thenAnswer(invocationOnMock -> { + throw new JWTVerificationException(""); + }); + + var decodedJWT = Mockito.mock(DecodedJWT.class); + Mockito.when(validator.validate("token3000", "42")).thenReturn(decodedJWT); + Settings settingsMock = new Settings(); + settingsMock.hubId = "42"; + settingsMock.licenseKey = "token3000"; + settingsClass.when(Settings::get).thenReturn(settingsMock); + + holder.init(); + + // init implicitly called due to @PostConstruct which increases the times to verify by 1 + // See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information + Mockito.verify(validator, Mockito.times(2)).validate("token3000", "42"); + Mockito.verify(session, Mockito.never()).persist(Mockito.any()); + Assertions.assertEquals(decodedJWT, holder.get()); + } + + @Test + @DisplayName("Setting a valid token validates and overwrites the init token") + public void testSetValidToken() { + var decodedJWT = Mockito.mock(DecodedJWT.class); + Mockito.when(validator.validate("token3000", "42")).thenReturn(decodedJWT); + + Settings initSettingsMock = new Settings(); + initSettingsMock.hubId = "42"; + settingsClass.when(Settings::get).thenReturn(initSettingsMock); + + var newLicensePersisted = new AtomicBoolean(false); + Mockito.doAnswer(invocation -> { + Settings settings = invocation.getArgument(0); + if (settings.hubId.equals("42") && settings.licenseKey.equals("token3000")) { + newLicensePersisted.set(true); + } + return null; + }).when(session).persist(Mockito.any()); + + holder.set("token3000"); + + Mockito.verify(validator, Mockito.times(1)).validate("token3000", "42"); + Assertions.assertTrue(newLicensePersisted.get()); + Assertions.assertEquals(decodedJWT, holder.get()); + } + + } } diff --git a/backend/src/test/java/org/cryptomator/hub/license/LicenseValidatorTest.java b/backend/src/test/java/org/cryptomator/hub/license/LicenseValidatorTest.java index 31223bfd8..f409fab8f 100644 --- a/backend/src/test/java/org/cryptomator/hub/license/LicenseValidatorTest.java +++ b/backend/src/test/java/org/cryptomator/hub/license/LicenseValidatorTest.java @@ -7,10 +7,13 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.util.Optional; + public class LicenseValidatorTest { private static final String VALID_TOKEN = "eyJhbGciOiJFUzUxMiJ9.eyJqdGkiOiI0MiIsImlhdCI6MTY0ODA0OTM2MCwiaXNzIjoiU2t5bWF0aWMiLCJhdWQiOiJDcnlwdG9tYXRvciBIdWIiLCJzdWIiOiJodWJAY3J5cHRvbWF0b3Iub3JnIiwic2VhdHMiOjUsImV4cCI6MjUzNDAyMjE0NDAwLCJyZWZyZXNoVXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2h1Yi9zdWJzY3JpcHRpb24_aHViX2lkPTQyIn0.AKyoZ0WQ8xhs8vPymWPHCsc6ch6pZpfxBcrF5QjVLSQVnYz2s5QF3nnkwn4AGR7V14TuhkJMZLUZxMdQAYLyL95sAV2Fu0E4-e1v3IVKlNKtze89eqYvEs6Ak9jWjtecOgPWNWjz2itI4MfJBDmbFtTnehOtqRqUdsDoC9NFik2C7tHm"; private static final String EXPIRED_TOKEN = "eyJhbGciOiJFUzUxMiJ9.eyJqdGkiOiI0MiIsImlhdCI6MTY3NzA4MzI1OSwiaXNzIjoiU2t5bWF0aWMiLCJhdWQiOiJDcnlwdG9tYXRvciBIdWIiLCJzdWIiOiJodWJAY3J5cHRvbWF0b3Iub3JnIiwic2VhdHMiOjUsImV4cCI6OTQ2Njg0ODAwLCJyZWZyZXNoVXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2h1Yi9zdWJzY3JpcHRpb24_aHViX2lkPTQyIn0.APQnWig9ZyT6_xRviPVs3YPTaP1w_YXTpWULgvsUpCGmGQwEmT6nl0x2jNB_jkQi93E7tr9WvipvX5DkXUOYJP3OAJjzPdN7rTX2tnXTKO8irshkcqmvt79v1E4k50YLkwP-1NIwiO_ltp5sezhLbzOVPXRag6mQfc0KvS6PiZTYGYQh"; + private static final String FUTURE_TOKEN = "eyJhbGciOiJFUzUxMiJ9.eyJqdGkiOjQyLCJpYXQiOjE3MDEyNDkzMzEzMSwiaXNzIjoiU2t5bWF0aWMiLCJhdWQiOiJDcnlwdG9tYXRvciBIdWIiLCJzdWIiOiJ0b2JpYXMuaGFnZW1hbm5Ac2t5bWF0aWMuZGUiLCJzZWF0cyI6NSwiZXhwIjoxNzIyMzg0MDAwLCJyZWZyZXNoVXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2h1Yi9zdWJzY3JpcHRpb24_aHViX2lkPTQyIn0.ALd0oyPR3kgntysXp8TZ1LvmHYDiDIGlbmaq52d5wAE1V8MZ1asWvufXgL9YExXvJhFbGCnLu66XgA387rxjrxKeASL_q43ZZUEDxtm8aa7uH2VMOvdM3gXEibSHUzNwO0MRWFbeYWOc8daRNWdxgOcrpX6NcMV7vPZH7yZSEct_cqf5"; 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"; @@ -45,6 +48,13 @@ public void testValidateExpiredToken() { validator.validate(EXPIRED_TOKEN, "42"); } + @Test + @DisplayName("validate future token") + public void testValidateFutureToken() { + // this should not throw an exception and return a JWT with an issued at in the future + validator.validate(FUTURE_TOKEN, "42"); + } + @Test @DisplayName("validate token with invalid signature") public void testValidateTokenWithInvalidSignature() { @@ -61,4 +71,25 @@ public void testValidateMalformedToken() { }); } + @Test + @DisplayName("validate token's refreshURL") + public void testGetTokensRefreshUrl() { + Assertions.assertEquals(Optional.of("http://localhost:8787/hub/subscription?hub_id=42"), validator.refreshUrl(VALID_TOKEN)); + } + + @Test + @DisplayName("validate expired token's refreshURL") + public void testGetExpiredTokensRefreshUrl() { + // this should not throw an exception and return a JWT with an expired date + Assertions.assertEquals(Optional.of("http://localhost:8787/hub/subscription?hub_id=42"), validator.refreshUrl(EXPIRED_TOKEN)); + } + + @Test + @DisplayName("validate expired token's refreshURL with invalid signature") + public void testInvalidSignatureTokensRefreshUrl() { + Assertions.assertThrows(SignatureVerificationException.class, () -> { + validator.refreshUrl(TOKEN_WITH_INVALID_SIGNATURE); + }); + } + } diff --git a/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResource.java b/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResource.java index 855b65255..b54e2a071 100644 --- a/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResource.java +++ b/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResource.java @@ -28,12 +28,6 @@ public Response probeOnlyBase64Chars(@PathParam("b64string") @OnlyBase64Chars St return Response.ok().build(); } - @GET - @Path("/onlybase64urlchars/{b64urlstring}") - public Response probeOnlyBase64UrlChars(@PathParam("b64urlstring") @OnlyBase64UrlChars String base64UrlString) { - return Response.ok().build(); - } - @GET @Path("/validjwe/{jwe}") public Response probeValidJWE(@PathParam("jwe") @ValidJWE String jwe) { diff --git a/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResourceTest.java b/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResourceTest.java index 4f8893576..33504ef23 100644 --- a/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResourceTest.java @@ -100,28 +100,6 @@ public void testOnlyBase64CharsInvalid(String toTest) { } } - @Nested - @DisplayName("Test @OnlyBase64UrlChars") - public class Base64UrlCharsTest { - - @DisplayName("Strings only containing base64url-chars are accepted") - @ParameterizedTest - @ValueSource(strings = {"abcdefghijklmnopqrstuvwxyz0123456789-_", "bGln-HQgd2_yaw==", "-======"}) - public void testOnlyBase64UrlCharsValid(String toTest) { - when().get("/test/onlybase64urlchars/{b64String}", toTest) - .then().statusCode(200); - } - - @DisplayName("Strings containing not-base64url-chars (or wrong order) are rejected") - @ParameterizedTest - @ValueSource(strings = {"foo+/", "\u5207ä=", "abc==abc", "==="}) - @ArgumentsSource(MalicousStringsProvider.class) - public void testOnlyBase64UrlCharsInvalid(String toTest) { - when().get("/test/onlybase64urlchars/{b64String}", toTest) - .then().statusCode(400); - } - } - @Nested @DisplayName("Test @ValidJWE") public class JWETest { diff --git a/backend/src/test/resources/org/cryptomator/hub/flyway/V9999__Test_Data.sql b/backend/src/test/resources/org/cryptomator/hub/flyway/V9999__Test_Data.sql index 1e106f0f4..3ac426e73 100644 --- a/backend/src/test/resources/org/cryptomator/hub/flyway/V9999__Test_Data.sql +++ b/backend/src/test/resources/org/cryptomator/hub/flyway/V9999__Test_Data.sql @@ -14,10 +14,10 @@ VALUES ('group1', 'GROUP', 'Group Name 1'), ('group2', 'GROUP', 'Group Name 2'); -INSERT INTO "user_details" ("id") +INSERT INTO "user_details" ("id", "publickey", "privatekey", "setupcode") VALUES - ('user1'), - ('user2'); + ('user1', 'public1', 'private1', 'setup1'), + ('user2', NULL, NULL, NULL); INSERT INTO "group_details" ("id") VALUES @@ -39,29 +39,43 @@ VALUES 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEC1uWSXj2czCDwMTLWV5BFmwxdM6PX9p+Pk9Yf9rIf374m5XP1U8q79dBhLSIuaojsvOT39UUcPJROSD1FqYLued0rXiooIii1D3jaW6pmGVJFhodzC31cy5sfOYotrzF', 'MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCAHpFQ62QnGCEvYh/pE9QmR1C9aLcDItRbslbmhen/h1tt8AyMhskeenT+rAyyPhGhZANiAAQLW5ZJePZzMIPAxMtZXkEWbDF0zo9f2n4+T1h/2sh/fviblc/VTyrv10GEtIi5qiOy85Pf1RRw8lE5IPUWpgu553SteKigiKLUPeNpbqmYZUkWGh3MLfVzLmx85ii2vMU=', FALSE), - ('7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'Vault Archived', 'This is a archived vault.', '2020-02-20 20:20:20', 'salt3', 42, 'masterkey3', + ('7E57C0DE-0000-4000-8000-00010000AAAA', 'Vault Archived', 'This is a archived vault.', '2020-02-20 20:20:20', 'salt3', 42, 'masterkey3', 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEC1uWSXj2czCDwMTLWV5BFmwxdM6PX9p+Pk9Yf9rIf374m5XP1U8q79dBhLSIuaojsvOT39UUcPJROSD1FqYLued0rXiooIii1D3jaW6pmGVJFhodzC31cy5sfOYotrzF', 'MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCAHpFQ62QnGCEvYh/pE9QmR1C9aLcDItRbslbmhen/h1tt8AyMhskeenT+rAyyPhGhZANiAAQLW5ZJePZzMIPAxMtZXkEWbDF0zo9f2n4+T1h/2sh/fviblc/VTyrv10GEtIi5qiOy85Pf1RRw8lE5IPUWpgu553SteKigiKLUPeNpbqmYZUkWGh3MLfVzLmx85ii2vMU=', TRUE); -INSERT INTO "vault_access" ("vault_id", "authority_id") +INSERT INTO "vault_access" ("vault_id", "authority_id", "role") VALUES - ('7E57C0DE-0000-4000-8000-000100001111', 'user1'), - ('7E57C0DE-0000-4000-8000-000100001111', 'user2'), - ('7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'user1'), - ('7E57C0DE-0000-4000-8000-000100002222', 'group1'); /* user1, part of group1, has access to vault2 */ + ('7E57C0DE-0000-4000-8000-000100001111', 'user1', 'OWNER'), + ('7E57C0DE-0000-4000-8000-000100001111', 'user2', 'MEMBER'), + ('7E57C0DE-0000-4000-8000-00010000AAAA', 'user1', 'OWNER'), + ('7E57C0DE-0000-4000-8000-000100002222', 'group2', 'OWNER'), + ('7E57C0DE-0000-4000-8000-000100002222', 'group1', 'MEMBER'); -INSERT INTO "device" ("id", "owner_id", "name", "type", "publickey", "creation_time") +INSERT INTO "device" ("id", "owner_id", "name", "type", "publickey", "creation_time", "user_privatekey") VALUES - ('device1', 'user1', 'Computer 1', 'DESKTOP', 'publickey1', '2020-02-20 20:20:20'), - ('device2', 'user2', 'Computer 2', 'DESKTOP', 'publickey2', '2020-02-20 20:20:20'), - ('device3', 'user1', 'Computer 3', 'DESKTOP', 'publickey3', '2020-02-20 20:20:20'); /* user1 is part of group1 */ + ('device1', 'user1', 'Computer 1', 'DESKTOP', 'publickey1', '2020-02-20 20:20:20', 'jwe.jwe.jwe.user1.device1'), + ('device2', 'user2', 'Computer 2', 'DESKTOP', 'publickey2', '2020-02-20 20:20:20', 'jwe.jwe.jwe.user2.device2'), + ('device3', 'user1', 'Computer 3', 'DESKTOP', 'publickey3', '2020-02-20 20:20:20', 'jwe.jwe.jwe.user1.device3'); -INSERT INTO "access_token" ("device_id", "vault_id", "jwe") +INSERT INTO "access_token" ("user_id", "vault_id", "vault_masterkey") VALUES - ('device1', '7E57C0DE-0000-4000-8000-000100001111', 'jwe1'), - ('device2', '7E57C0DE-0000-4000-8000-000100001111', 'jwe2'), - ('device3', '7E57C0DE-0000-4000-8000-000100002222', 'jwe3'); -- device3 of user1, part of group1, has access to vault2 + ('user1', '7E57C0DE-0000-4000-8000-000100001111', 'jwe.jwe.jwe.vault1.user1'), -- direct access + ('user2', '7E57C0DE-0000-4000-8000-000100001111', 'jwe.jwe.jwe.vault1.user2'), -- direct access + ('user1', '7E57C0DE-0000-4000-8000-000100002222', 'jwe.jwe.jwe.vault2.user1'); -- access via group1 + +-- DEPRECATED: +INSERT INTO "device_legacy" ("id", "owner_id", "name", "type", "publickey", "creation_time") +VALUES + ('legacyDevice1', 'user1', 'Computer 1', 'DESKTOP', 'publickey1', '2020-02-20 20:20:20'), + ('legacyDevice2', 'user2', 'Computer 2', 'DESKTOP', 'publickey2', '2020-02-20 20:20:20'), + ('legacyDevice3', 'user1', 'Computer 3', 'DESKTOP', 'publickey3', '2020-02-20 20:20:20'); + +INSERT INTO "access_token_legacy" ("device_id", "vault_id", "jwe") +VALUES + ('legacyDevice1', '7E57C0DE-0000-4000-8000-000100001111', 'legacy.jwe.jwe.vault1.device1'), -- direct access + ('legacyDevice2', '7E57C0DE-0000-4000-8000-000100001111', 'legacy.jwe.jwe.vault1.device2'), -- direct access + ('legacyDevice3', '7E57C0DE-0000-4000-8000-000100002222', 'legacy.jwe.jwe.vault2.device3'); -- access via group1 INSERT INTO "audit_event" ("id", "timestamp", "type") VALUES @@ -72,6 +86,8 @@ VALUES (21, '2020-02-20T20:20:20.021Z', 'VAULT_MEMBER_ADD'), (22, '2020-02-20T20:20:20.022Z', 'VAULT_MEMBER_ADD'), (23, '2020-02-20T20:20:20.023Z', 'VAULT_MEMBER_REMOVE'), + (24, '2020-02-20T20:20:20.024Z', 'VAULT_MEMBER_UPDATE'), + (25, '2020-02-20T20:20:20.025Z', 'VAULT_MEMBER_UPDATE'), (30, '2020-02-20T20:20:20.030Z', 'VAULT_CREATE'), (31, '2020-02-20T20:20:20.031Z', 'VAULT_MEMBER_ADD'), (100, '2020-02-20T20:20:20.100Z', 'DEVICE_REGISTER'), @@ -93,20 +109,25 @@ INSERT INTO "audit_event_vault_create" ("id", "created_by", "vault_id", "vault_n VALUES (10, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'Vault 1', 'This is a testvault.'), (20, 'user1', '7E57C0DE-0000-4000-8000-000100002222', 'Vault 2', 'This is a testvault.'), - (30, 'user2', '7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'Vault 3', 'This is a testvault.'); + (30, 'user2', '7E57C0DE-0000-4000-8000-00010000AAAA', 'Vault 3', 'This is a testvault.'); -INSERT INTO "audit_event_vault_member_add" ("id", "added_by", "vault_id", "authority_id") +INSERT INTO "audit_event_vault_member_add" ("id", "added_by", "vault_id", "authority_id", "role") VALUES - (11, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'user1'), - (12, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'user2'), - (21, 'user1', '7E57C0DE-0000-4000-8000-000100002222', 'user1'), - (22, 'user1', '7E57C0DE-0000-4000-8000-000100002222', 'group1'), - (31, 'user2', '7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'user2'); + (11, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'user1', 'OWNER'), + (12, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'user2', 'MEMBER'), + (21, 'user1', '7E57C0DE-0000-4000-8000-000100002222', 'user1', 'MEMBER'), + (22, 'user1', '7E57C0DE-0000-4000-8000-000100002222', 'group1', 'MEMBER'), + (31, 'user2', '7E57C0DE-0000-4000-8000-00010000AAAA', 'user1', 'MEMBER'); INSERT INTO "audit_event_vault_member_remove" ("id", "removed_by", "vault_id", "authority_id") VALUES (23, 'user1', '7E57C0DE-0000-4000-8000-000100002222', 'user1'); +INSERT INTO "audit_event_vault_member_update" ("id", "updated_by", "vault_id", "authority_id", "role") +VALUES + (24, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'user2', 'OWNER'), + (25, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'user2', 'MEMBER'); + INSERT INTO "audit_event_device_register" ("id", "registered_by", "device_id", "device_name", "device_type") VALUES (100, 'user1', 'device1', 'Computer 1', 'DESKTOP'), @@ -127,9 +148,9 @@ INSERT INTO "audit_event_vault_access_grant" ("id", "granted_by", "vault_id", "a VALUES (2000, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'user1'), (2001, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'user2'), - (2002, 'user1', '7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'user1'), + (2002, 'user1', '7E57C0DE-0000-4000-8000-00010000AAAA', 'user1'), (2003, 'user1', '7E57C0DE-0000-4000-8000-000100002222', 'group1'); INSERT INTO "audit_event_vault_update" ("id", "updated_by", "vault_id", "vault_name", "vault_description", "vault_archived") VALUES - (3000, 'user1', '7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'Vault Archived', 'This is a archived vault.', TRUE); + (3000, 'user1', '7E57C0DE-0000-4000-8000-00010000AAAA', 'Vault Archived', 'This is a archived vault.', TRUE); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 16140c265..968cae889 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,57 +1,57 @@ { "name": "cryptomator-hub", - "version": "1.2.2", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cryptomator-hub", - "version": "1.2.2", + "version": "1.3.0", "license": "AGPL-3.0-or-later", "dependencies": { - "@headlessui/tailwindcss": "^0.1.3", - "@headlessui/vue": "^1.7.14", + "@headlessui/tailwindcss": "^0.2.0", + "@headlessui/vue": "^1.7.16", "@heroicons/vue": "^2.0.18", - "axios": "^1.4.0", + "axios": "^1.6.0", "file-saver": "^2.0.5", "jdenticon": "^3.2.0", "jszip": "^3.10.1", - "keycloak-js": "^22.0.1", + "keycloak-js": "^22.0.5", "miscreant": "^0.3.2", - "rfc4648": "^1.5.2", - "semver": "^7.5.3", + "rfc4648": "^1.5.3", + "semver": "^7.5.4", "vue": "^3.3.4", - "vue-i18n": "^9.2.2", - "vue-router": "^4.2.2" + "vue-i18n": "^9.6.2", + "vue-router": "^4.2.5" }, "devDependencies": { - "@intlify/unplugin-vue-i18n": "^0.11.0", - "@tailwindcss/forms": "^0.5.3", - "@types/blueimp-md5": "^2.18.0", - "@types/chai": "^4.3.5", - "@types/chai-as-promised": "^7.1.5", - "@types/file-saver": "^2.0.5", - "@types/mocha": "^10.0.1", - "@types/node": "^20.3.2", - "@types/semver": "^7.5.0", - "@typescript-eslint/eslint-plugin": "^5.60.1", - "@typescript-eslint/parser": "^5.60.1", - "@vitejs/plugin-vue": "^4.2.3", + "@intlify/unplugin-vue-i18n": "^1.4.0", + "@tailwindcss/forms": "^0.5.6", + "@types/blueimp-md5": "^2.18.1", + "@types/chai": "^4.3.9", + "@types/chai-as-promised": "^7.1.7", + "@types/file-saver": "^2.0.6", + "@types/mocha": "^10.0.3", + "@types/node": "^20.8.10", + "@types/semver": "^7.5.4", + "@typescript-eslint/eslint-plugin": "^6.9.1", + "@typescript-eslint/parser": "^6.9.1", + "@vitejs/plugin-vue": "^4.4.0", "@vue/compiler-sfc": "^3.3.4", - "autoprefixer": "^10.4.14", - "chai": "^4.3.7", + "autoprefixer": "^10.4.16", + "chai": "^4.3.10", "chai-as-promised": "^7.1.1", "chai-bytes": "^0.1.2", - "eslint": "^8.43.0", - "eslint-plugin-vue": "^9.15.1", + "eslint": "^8.52.0", + "eslint-plugin-vue": "^9.18.1", "mocha": "^10.2.0", "nyc": "^15.1.0", - "postcss": "^8.4.24", - "tailwindcss": "^3.3.2", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", "ts-node": "^10.9.1", - "typescript": "^5.1.6", - "vite": "^4.3.9", - "vue-tsc": "^1.8.3" + "typescript": "^5.2.2", + "vite": "^4.5.0", + "vue-tsc": "^1.8.22" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -88,47 +88,119 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz", - "integrity": "sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==", + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", "dev": true, "dependencies": { - "@babel/highlight": "^7.22.5" + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/compat-data": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.5.tgz", - "integrity": "sha512-4Jc/YuIaYqKnDDz892kPIledykKg12Aw1PYX5i/TY28anJtacvM1Rrr8wbieB9GfEJwlzqT0hUEao0CxEebiDA==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.2.tgz", + "integrity": "sha512-0S9TQMmDHlqAZ2ITT95irXKfxN9bncq8ZCoJhun3nHL/lLUxd2NKBJYoNGWH7S0hz6fRQwWlAWn/ILM0C70KZQ==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.5.tgz", - "integrity": "sha512-SBuTAjg91A3eKOvD+bPEz3LlhHZRNu1nFOVts9lzDJTXshHTjII0BAtDS3Y2DAkdZdDKWVZGVwkDfc4Clxn1dg==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", + "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.22.5", - "@babel/generator": "^7.22.5", - "@babel/helper-compilation-targets": "^7.22.5", - "@babel/helper-module-transforms": "^7.22.5", - "@babel/helpers": "^7.22.5", - "@babel/parser": "^7.22.5", - "@babel/template": "^7.22.5", - "@babel/traverse": "^7.22.5", - "@babel/types": "^7.22.5", - "convert-source-map": "^1.7.0", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.0", + "@babel/helpers": "^7.23.2", + "@babel/parser": "^7.23.0", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0", + "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", - "json5": "^2.2.2", - "semver": "^6.3.0" + "json5": "^2.2.3", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" @@ -138,22 +210,28 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/generator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.5.tgz", - "integrity": "sha512-+lcUbnTRhd0jOewtFSedLyiPsD5tswKkbgcezOqqWFUVNEwoUTlpPOBmvhG7OXWLR4jMdv0czPGH5XbflnD1EA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5", + "@babel/types": "^7.23.0", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -163,50 +241,47 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.5.tgz", - "integrity": "sha512-Ji+ywpHeuqxB8WDxraCiqR0xfhYjiDE/e6k7FuIaANnoOFxAHskHChz4vA1mJC9Lbm01s1PVAGhQY4FUKSkGZw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", + "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.22.5", - "@babel/helper-validator-option": "^7.22.5", - "browserslist": "^4.21.3", + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.15", + "browserslist": "^4.21.9", "lru-cache": "^5.1.1", - "semver": "^6.3.0" + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" } }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz", - "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", - "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "dependencies": { - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" @@ -225,34 +300,34 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz", - "integrity": "sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.5.tgz", - "integrity": "sha512-+hGKDt/Ze8GFExiVHno/2dvG5IdstpzCq0y4Qc9OJ25D4q3pKfiIP/4Vp3/JvhDkLKsDK2api3q3fpIgiIF5bw==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.0.tgz", + "integrity": "sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-module-imports": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5", - "@babel/template": "^7.22.5", - "@babel/traverse": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, "node_modules/@babel/helper-simple-access": { @@ -268,9 +343,9 @@ } }, "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.5.tgz", - "integrity": "sha512-thqK5QFghPKWLhAV321lxF95yCg2K3Ob5yw+M3VHWfdia0IkPXUtoLH8x/6Fh486QUvzhb8YOWHChTVen2/PoQ==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", "dev": true, "dependencies": { "@babel/types": "^7.22.5" @@ -289,45 +364,45 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz", - "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz", - "integrity": "sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", + "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.5.tgz", - "integrity": "sha512-pSXRmfE1vzcUIDFQcSGA5Mr+GxBV9oiRKDuDxXvWQQBCh8HoIjs/2DlDB7H8smac1IVrB9/xdXj2N3Wol9Cr+Q==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz", + "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==", "dev": true, "dependencies": { - "@babel/template": "^7.22.5", - "@babel/traverse": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.5.tgz", - "integrity": "sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.22.5", - "chalk": "^2.0.0", + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", "js-tokens": "^4.0.0" }, "engines": { @@ -406,9 +481,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.5.tgz", - "integrity": "sha512-DFZMC9LJUG9PLOclRC32G63UXwzqS2koQC8dkx+PLdmt1xSePYpbT/NbsrJy8Q/muXz7o/h/d4A7Fuyixm559Q==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", "bin": { "parser": "bin/babel-parser.js" }, @@ -417,33 +492,33 @@ } }, "node_modules/@babel/template": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", - "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.22.5", - "@babel/parser": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.5.tgz", - "integrity": "sha512-7DuIjPgERaNo6r+PZwItpjCZEa5vyw4eJGufeLxrPdBXBoLcCJCIasvK6pK/9DVNrLZTLFhUGqaC6X/PA007TQ==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.22.5", - "@babel/generator": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.5", - "@babel/parser": "^7.22.5", - "@babel/types": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -461,13 +536,13 @@ } }, "node_modules/@babel/types": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.5.tgz", - "integrity": "sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { @@ -497,9 +572,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", - "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", "cpu": [ "arm" ], @@ -513,9 +588,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", - "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", "cpu": [ "arm64" ], @@ -529,9 +604,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", - "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", "cpu": [ "x64" ], @@ -545,9 +620,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", - "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", "cpu": [ "arm64" ], @@ -561,9 +636,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", - "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", "cpu": [ "x64" ], @@ -577,9 +652,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", - "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", "cpu": [ "arm64" ], @@ -593,9 +668,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", - "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", "cpu": [ "x64" ], @@ -609,9 +684,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", - "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", "cpu": [ "arm" ], @@ -625,9 +700,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", - "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", "cpu": [ "arm64" ], @@ -641,9 +716,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", - "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", "cpu": [ "ia32" ], @@ -657,9 +732,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", - "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", "cpu": [ "loong64" ], @@ -673,9 +748,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", - "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", "cpu": [ "mips64el" ], @@ -689,9 +764,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", - "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", "cpu": [ "ppc64" ], @@ -705,9 +780,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", - "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", "cpu": [ "riscv64" ], @@ -721,9 +796,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", - "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", "cpu": [ "s390x" ], @@ -737,9 +812,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", - "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", "cpu": [ "x64" ], @@ -753,9 +828,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", - "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", "cpu": [ "x64" ], @@ -769,9 +844,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", - "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", "cpu": [ "x64" ], @@ -785,9 +860,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", - "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", "cpu": [ "x64" ], @@ -801,9 +876,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", - "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", "cpu": [ "arm64" ], @@ -817,9 +892,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", - "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", "cpu": [ "ia32" ], @@ -833,9 +908,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", - "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", "cpu": [ "x64" ], @@ -864,23 +939,23 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz", - "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==", + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/eslintrc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", - "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.2.tgz", + "integrity": "sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g==", "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.5.2", + "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -896,18 +971,18 @@ } }, "node_modules/@eslint/js": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.43.0.tgz", - "integrity": "sha512-s2UHCoiXfxMvmfzqoN+vrQ84ahUSYde9qNO1MdxmoEhyHWsfmwOpFlwYV+ePJEVc7gFnATGUi376WowX1N7tFg==", + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.52.0.tgz", + "integrity": "sha512-mjZVbpaeMZludF2fsWLD0Z9gCref1Tk4i9+wddjRvpUNqqcndPkBD09N/Mapey0b3jaXbLm2kICwFv2E64QinA==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/@headlessui/tailwindcss": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.1.3.tgz", - "integrity": "sha512-3aMdDyYZx9A15euRehpppSyQnb2gIw2s/Uccn2ELIoLQ9oDy0+9oRygNWNjXCD5Dt+w1pxo7C+XoiYvGcqA4Kg==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.0.tgz", + "integrity": "sha512-fpL830Fln1SykOCboExsWr3JIVeQKieLJ3XytLe/tt1A0XzqUthOftDmjcCYLW62w7mQI7wXcoPXr3tZ9QfGxw==", "engines": { "node": ">=10" }, @@ -916,9 +991,9 @@ } }, "node_modules/@headlessui/vue": { - "version": "1.7.14", - "resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.14.tgz", - "integrity": "sha512-aL9U9Sa7wdOzlrfjx6EjMIYNRCma5mngWcWzQBcHFwznpRZ8g/QZ/AYFtRDrZZUw22Ttttja4D7ZRXFwhONewA==", + "version": "1.7.16", + "resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.16.tgz", + "integrity": "sha512-nKT+nf/q6x198SsyK54mSszaQl/z+QxtASmgMEJtpxSX2Q0OPJX0upS/9daDyiECpeAsvjkoOrm2O/6PyBQ+Qg==", "engines": { "node": ">=10" }, @@ -935,12 +1010,12 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", - "integrity": "sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==", + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", + "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^1.2.1", + "@humanwhocodes/object-schema": "^2.0.1", "debug": "^4.1.1", "minimatch": "^3.0.5" }, @@ -962,27 +1037,27 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", + "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, "node_modules/@intlify/bundle-utils": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-6.0.1.tgz", - "integrity": "sha512-BkeZNKZiC0B7K3OYMwiPLoAqsZmKH3SxTL75vYAkuQ//XWR8WO0NpfjXhTxgLTVFHxMcNb2agAopC0DP6fqDrg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-7.4.0.tgz", + "integrity": "sha512-AQfjBe2HUxzyN8ignIk3WhhSuVcSuirgzOzkd17nb337rCbI4Gv/t1R60UUyIqFoFdviLb/wLcDUzTD/xXjv9w==", "dev": true, "dependencies": { - "@intlify/message-compiler": "9.3.0-beta.17", - "@intlify/shared": "9.3.0-beta.17", + "@intlify/message-compiler": "^9.4.0", + "@intlify/shared": "^9.4.0", "acorn": "^8.8.2", "escodegen": "^2.0.0", "estree-walker": "^2.0.2", - "jsonc-eslint-parser": "^1.0.1", + "jsonc-eslint-parser": "^2.3.0", "magic-string": "^0.30.0", "mlly": "^1.2.0", - "source-map": "0.6.1", - "yaml-eslint-parser": "^0.3.2" + "source-map-js": "^1.0.1", + "yaml-eslint-parser": "^1.2.2" }, "engines": { "node": ">= 14.16" @@ -997,94 +1072,54 @@ } }, "node_modules/@intlify/core-base": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.2.2.tgz", - "integrity": "sha512-JjUpQtNfn+joMbrXvpR4hTF8iJQ2sEFzzK3KIESOx+f+uwIjgw20igOyaIdhfsVVBCds8ZM64MoeNSx+PHQMkA==", + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.6.2.tgz", + "integrity": "sha512-ci0j2nbEL/pamvqgcCqyIVeQ3LS41F1IRqI5rCBNnpSp0FjNnH8bpha8R3OifkhqatzlP4wGOuN/UqfLYVDv7g==", "dependencies": { - "@intlify/devtools-if": "9.2.2", - "@intlify/message-compiler": "9.2.2", - "@intlify/shared": "9.2.2", - "@intlify/vue-devtools": "9.2.2" + "@intlify/message-compiler": "9.6.2", + "@intlify/shared": "9.6.2" }, "engines": { - "node": ">= 14" - } - }, - "node_modules/@intlify/core-base/node_modules/@intlify/message-compiler": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.2.2.tgz", - "integrity": "sha512-IUrQW7byAKN2fMBe8z6sK6riG1pue95e5jfokn8hA5Q3Bqy4MBJ5lJAofUsawQJYHeoPJ7svMDyBaVJ4d0GTtA==", - "dependencies": { - "@intlify/shared": "9.2.2", - "source-map": "0.6.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@intlify/core-base/node_modules/@intlify/shared": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.2.2.tgz", - "integrity": "sha512-wRwTpsslgZS5HNyM7uDQYZtxnbI12aGiBZURX3BTR9RFIKKRWpllTsgzHWvj3HKm3Y2Sh5LPC1r0PDCKEhVn9Q==", - "engines": { - "node": ">= 14" - } - }, - "node_modules/@intlify/devtools-if": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@intlify/devtools-if/-/devtools-if-9.2.2.tgz", - "integrity": "sha512-4ttr/FNO29w+kBbU7HZ/U0Lzuh2cRDhP8UlWOtV9ERcjHzuyXVZmjyleESK6eVP60tGC9QtQW9yZE+JeRhDHkg==", - "dependencies": { - "@intlify/shared": "9.2.2" + "node": ">= 16" }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@intlify/devtools-if/node_modules/@intlify/shared": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.2.2.tgz", - "integrity": "sha512-wRwTpsslgZS5HNyM7uDQYZtxnbI12aGiBZURX3BTR9RFIKKRWpllTsgzHWvj3HKm3Y2Sh5LPC1r0PDCKEhVn9Q==", - "engines": { - "node": ">= 14" + "funding": { + "url": "https://github.com/sponsors/kazupon" } }, "node_modules/@intlify/message-compiler": { - "version": "9.3.0-beta.17", - "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.3.0-beta.17.tgz", - "integrity": "sha512-i7hvVIRk1Ax2uKa9xLRJCT57to08OhFMhFXXjWN07rmx5pWQYQ23MfX1xgggv9drnWTNhqEiD+u4EJeHoS5+Ww==", - "dev": true, + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.6.2.tgz", + "integrity": "sha512-kgZQL9zeJDeEB5vvD93Y++HvFUELnT48PjnpfCcF3EJaLLVs9he8IzODiNK42Z40lWbFyja0SXJZjsalybQygA==", "dependencies": { - "@intlify/shared": "9.3.0-beta.17", - "source-map": "0.6.1" + "@intlify/shared": "9.6.2", + "source-map-js": "^1.0.2" }, "engines": { - "node": ">= 14" + "node": ">= 16" }, "funding": { "url": "https://github.com/sponsors/kazupon" } }, "node_modules/@intlify/shared": { - "version": "9.3.0-beta.17", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.3.0-beta.17.tgz", - "integrity": "sha512-mscf7RQsUTOil35jTij4KGW1RC9SWQjYScwLxP53Ns6g24iEd5HN7ksbt9O6FvTmlQuX77u+MXpBdfJsGqizLQ==", - "dev": true, + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.6.2.tgz", + "integrity": "sha512-9KBcXmJNxElp7QMnU8V0/tScTOitDqyFi4HceEZqJyyDkMi8K5DBPMTIuXIAMmtMlXpe/nj5pke7tRw97VeQRA==", "engines": { - "node": ">= 14" + "node": ">= 16" }, "funding": { "url": "https://github.com/sponsors/kazupon" } }, "node_modules/@intlify/unplugin-vue-i18n": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@intlify/unplugin-vue-i18n/-/unplugin-vue-i18n-0.11.0.tgz", - "integrity": "sha512-ivcLZo08fvepHWV8o5lcKfhcKFSWqhwrqIAU6pUIbvq2ICo9fnXnIPYIZj7FeuHDLW1G3ADm44ZhQC3nYmvDlg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@intlify/unplugin-vue-i18n/-/unplugin-vue-i18n-1.4.0.tgz", + "integrity": "sha512-RGDchCRBlDTyVVFgPA1C1XC1uD4xYN81Ma+3EnU6GQ8pBEreraX/PWdPXXzOB6k9GWCQHuqii3atYXhcH3rpSg==", "dev": true, "dependencies": { - "@intlify/bundle-utils": "^6.0.1", - "@intlify/shared": "9.3.0-beta.17", + "@intlify/bundle-utils": "^7.4.0", + "@intlify/shared": "^9.4.0", "@rollup/pluginutils": "^5.0.2", "@vue/compiler-sfc": "^3.2.47", "debug": "^4.3.3", @@ -1093,7 +1128,7 @@ "json5": "^2.2.3", "pathe": "^1.0.0", "picocolors": "^1.0.0", - "source-map": "0.6.1", + "source-map-js": "^1.0.2", "unplugin": "^1.1.0" }, "engines": { @@ -1116,26 +1151,6 @@ } } }, - "node_modules/@intlify/vue-devtools": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@intlify/vue-devtools/-/vue-devtools-9.2.2.tgz", - "integrity": "sha512-+dUyqyCHWHb/UcvY1MlIpO87munedm3Gn6E9WWYdWrMuYLcoIoOEVDWSS8xSwtlPU+kA+MEQTP6Q1iI/ocusJg==", - "dependencies": { - "@intlify/core-base": "9.2.2", - "@intlify/shared": "9.2.2" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@intlify/vue-devtools/node_modules/@intlify/shared": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.2.2.tgz", - "integrity": "sha512-wRwTpsslgZS5HNyM7uDQYZtxnbI12aGiBZURX3BTR9RFIKKRWpllTsgzHWvj3HKm3Y2Sh5LPC1r0PDCKEhVn9Q==", - "engines": { - "node": ">= 14" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1258,9 +1273,9 @@ } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", "engines": { "node": ">=6.0.0" } @@ -1279,19 +1294,14 @@ "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.18", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", - "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", "dependencies": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jridgewell/trace-mapping/node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1325,9 +1335,9 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", - "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.5.tgz", + "integrity": "sha512-6aEYR910NyP73oHiJglti74iRyOwgFU4x3meH/H8OJx6Ry0j6cOVZ5X/wTvub7G7Ao6qaHBEaNsV3GLJkSsF+Q==", "dev": true, "dependencies": { "@types/estree": "^1.0.0", @@ -1338,7 +1348,7 @@ "node": ">=14.0.0" }, "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0" + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "peerDependenciesMeta": { "rollup": { @@ -1347,9 +1357,9 @@ } }, "node_modules/@tailwindcss/forms": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.3.tgz", - "integrity": "sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==", + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.6.tgz", + "integrity": "sha512-Fw+2BJ0tmAwK/w01tEFL5TiaJBX1NLT1/YbWgvm7ws3Qcn11kiXxzNTEQDMs5V3mQemhB56l3u0i9dwdzSQldA==", "dev": true, "dependencies": { "mini-svg-data-uri": "^1.2.3" @@ -1383,88 +1393,92 @@ "devOptional": true }, "node_modules/@types/blueimp-md5": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/@types/blueimp-md5/-/blueimp-md5-2.18.0.tgz", - "integrity": "sha512-f4A+++lGZGJvVSgeyMkqA7BEf2BVQli6F+qEykKb49c5ieWQBkfpn6CP5c1IZr2Yi2Ofl6Fj+v0e1fN18Z8Cnw==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@types/blueimp-md5/-/blueimp-md5-2.18.1.tgz", + "integrity": "sha512-0Ud2ef0DMurdLPj63BmiB01E98F1UNrwdBEflTpZtE3Z4jhhCclCiS88XDWXagiOaG+RjfliyvOBTAMUMw7jnw==", "dev": true }, "node_modules/@types/chai": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.5.tgz", - "integrity": "sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==", + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.9.tgz", + "integrity": "sha512-69TtiDzu0bcmKQv3yg1Zx409/Kd7r0b5F1PfpYJfSHzLGtB53547V4u+9iqKYsTu/O2ai6KTb0TInNpvuQ3qmg==", "dev": true }, "node_modules/@types/chai-as-promised": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-7.1.5.tgz", - "integrity": "sha512-jStwss93SITGBwt/niYrkf2C+/1KTeZCZl1LaeezTlqppAKeoQC7jxyqYuP72sxBGKCIbw7oHgbYssIRzT5FCQ==", + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-7.1.7.tgz", + "integrity": "sha512-APucaP5rlmTRYKtRA6FE5QPP87x76ejw5t5guRJ4y5OgMnwtsvigw7HHhKZlx2MGXLeZd6R/GNZR/IqDHcbtQw==", "dev": true, "dependencies": { "@types/chai": "*" } }, "node_modules/@types/estree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", - "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.4.tgz", + "integrity": "sha512-2JwWnHK9H+wUZNorf2Zr6ves96WHoWDJIftkcxPKsS7Djta6Zu519LarhRNljPXkpsZR2ZMwNCPeW7omW07BJw==", "dev": true }, "node_modules/@types/file-saver": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz", - "integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.6.tgz", + "integrity": "sha512-Mw671DVqoMHbjw0w4v2iiOro01dlT/WhWp5uwecBa0Wg8c+bcZOjgF1ndBnlaxhtvFCgTRBtsGivSVhrK/vnag==", "dev": true }, "node_modules/@types/json-schema": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", - "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz", + "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==", "dev": true }, "node_modules/@types/mocha": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz", - "integrity": "sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.3.tgz", + "integrity": "sha512-RsOPImTriV/OE4A9qKjMtk2MnXiuLLbcO3nCXK+kvq4nr0iMfFgpjaX3MPLb6f7+EL1FGSelYvuJMV6REH+ZPQ==", "dev": true }, "node_modules/@types/node": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.2.tgz", - "integrity": "sha512-vOBLVQeCQfIcF/2Y7eKFTqrMnizK5lRNQ7ykML/5RuwVXVWxYkgwS7xbt4B6fKCUPgbSL5FSsjHQpaGQP/dQmw==" + "version": "20.8.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.10.tgz", + "integrity": "sha512-TlgT8JntpcbmKUFzjhsyhGfP2fsiz1Mv56im6enJ905xG1DAYesxJaeSbGqQmAw8OWPdhyJGhGSQGKRNJ45u9w==", + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/semver": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz", - "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==", "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.60.1.tgz", - "integrity": "sha512-KSWsVvsJsLJv3c4e73y/Bzt7OpqMCADUO846bHcuWYSYM19bldbAeDv7dYyV0jwkbMfJ2XdlzwjhXtuD7OY6bw==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.9.1.tgz", + "integrity": "sha512-w0tiiRc9I4S5XSXXrMHOWgHgxbrBn1Ro+PmiYhSg2ZVdxrAJtQgzU5o2m1BfP6UOn7Vxcc6152vFjQfmZR4xEg==", "dev": true, "dependencies": { - "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.60.1", - "@typescript-eslint/type-utils": "5.60.1", - "@typescript-eslint/utils": "5.60.1", + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.9.1", + "@typescript-eslint/type-utils": "6.9.1", + "@typescript-eslint/utils": "6.9.1", + "@typescript-eslint/visitor-keys": "6.9.1", "debug": "^4.3.4", - "grapheme-splitter": "^1.0.4", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -1473,25 +1487,26 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.60.1.tgz", - "integrity": "sha512-pHWlc3alg2oSMGwsU/Is8hbm3XFbcrb6P5wIxcQW9NsYBfnrubl/GhVVD/Jm/t8HXhA2WncoIRfBtnCgRGV96Q==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.9.1.tgz", + "integrity": "sha512-C7AK2wn43GSaCUZ9do6Ksgi2g3mwFkMO3Cis96kzmgudoVaKyt62yNzJOktP0HDLb/iO2O0n2lBOzJgr6Q/cyg==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "5.60.1", - "@typescript-eslint/types": "5.60.1", - "@typescript-eslint/typescript-estree": "5.60.1", + "@typescript-eslint/scope-manager": "6.9.1", + "@typescript-eslint/types": "6.9.1", + "@typescript-eslint/typescript-estree": "6.9.1", + "@typescript-eslint/visitor-keys": "6.9.1", "debug": "^4.3.4" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -1500,16 +1515,16 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.60.1.tgz", - "integrity": "sha512-Dn/LnN7fEoRD+KspEOV0xDMynEmR3iSHdgNsarlXNLGGtcUok8L4N71dxUgt3YvlO8si7E+BJ5Fe3wb5yUw7DQ==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.9.1.tgz", + "integrity": "sha512-38IxvKB6NAne3g/+MyXMs2Cda/Sz+CEpmm+KLGEM8hx/CvnSRuw51i8ukfwB/B/sESdeTGet1NH1Wj7I0YXswg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.60.1", - "@typescript-eslint/visitor-keys": "5.60.1" + "@typescript-eslint/types": "6.9.1", + "@typescript-eslint/visitor-keys": "6.9.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", @@ -1517,25 +1532,25 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.60.1.tgz", - "integrity": "sha512-vN6UztYqIu05nu7JqwQGzQKUJctzs3/Hg7E2Yx8rz9J+4LgtIDFWjjl1gm3pycH0P3mHAcEUBd23LVgfrsTR8A==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.9.1.tgz", + "integrity": "sha512-eh2oHaUKCK58qIeYp19F5V5TbpM52680sB4zNSz29VBQPTWIlE/hCj5P5B1AChxECe/fmZlspAWFuRniep1Skg==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "5.60.1", - "@typescript-eslint/utils": "5.60.1", + "@typescript-eslint/typescript-estree": "6.9.1", + "@typescript-eslint/utils": "6.9.1", "debug": "^4.3.4", - "tsutils": "^3.21.0" + "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "*" + "eslint": "^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -1544,12 +1559,12 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "5.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.60.1.tgz", - "integrity": "sha512-zDcDx5fccU8BA0IDZc71bAtYIcG9PowaOwaD8rjYbqwK7dpe/UMQl3inJ4UtUK42nOCT41jTSCwg76E62JpMcg==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.9.1.tgz", + "integrity": "sha512-BUGslGOb14zUHOUmDB2FfT6SI1CcZEJYfF3qFwBeUrU6srJfzANonwRYHDpLBuzbq3HaoF2XL2hcr01c8f8OaQ==", "dev": true, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", @@ -1557,21 +1572,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.60.1.tgz", - "integrity": "sha512-hkX70J9+2M2ZT6fhti5Q2FoU9zb+GeZK2SLP1WZlvUDqdMbEKhexZODD1WodNRyO8eS+4nScvT0dts8IdaBzfw==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.9.1.tgz", + "integrity": "sha512-U+mUylTHfcqeO7mLWVQ5W/tMLXqVpRv61wm9ZtfE5egz7gtnmqVIw9ryh0mgIlkKk9rZLY3UHygsBSdB9/ftyw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.60.1", - "@typescript-eslint/visitor-keys": "5.60.1", + "@typescript-eslint/types": "6.9.1", + "@typescript-eslint/visitor-keys": "6.9.1", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", @@ -1584,52 +1599,57 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "5.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.60.1.tgz", - "integrity": "sha512-tiJ7FFdFQOWssFa3gqb94Ilexyw0JVxj6vBzaSpfN/8IhoKkDuSAenUKvsSHw2A/TMpJb26izIszTXaqygkvpQ==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.9.1.tgz", + "integrity": "sha512-L1T0A5nFdQrMVunpZgzqPL6y2wVreSyHhKGZryS6jrEN7bD9NplVAyMryUhXsQ4TWLnZmxc2ekar/lSGIlprCA==", "dev": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.60.1", - "@typescript-eslint/types": "5.60.1", - "@typescript-eslint/typescript-estree": "5.60.1", - "eslint-scope": "^5.1.1", - "semver": "^7.3.7" + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.9.1", + "@typescript-eslint/types": "6.9.1", + "@typescript-eslint/typescript-estree": "6.9.1", + "semver": "^7.5.4" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "^7.0.0 || ^8.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.60.1.tgz", - "integrity": "sha512-xEYIxKcultP6E/RMKqube11pGjXH1DCo60mQoWhVYyKfLkwbIVVjYxmOenNMxILx0TjCujPTjjnTIVzm09TXIw==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.9.1.tgz", + "integrity": "sha512-MUaPUe/QRLEffARsmNfmpghuQkW436DvESW+h+M52w0coICHRfD6Np9/K6PdACwnrq1HmuLl+cSPZaJmeVPkSw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.60.1", - "eslint-visitor-keys": "^3.3.0" + "@typescript-eslint/types": "6.9.1", + "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, "node_modules/@vitejs/plugin-vue": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.2.3.tgz", - "integrity": "sha512-R6JDUfiZbJA9cMiguQ7jxALsgiprjBeHL5ikpXfJCH62pPHtI+JdJ5xWj6Ev73yXSlYl86+blXn1kZHQ7uElxw==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.4.0.tgz", + "integrity": "sha512-xdguqb+VUwiRpSg+nsc2HtbAUSGak25DXYvpQQi4RVU1Xq1uworyoH/md9Rfd8zMmPR/pSghr309QNcftUVseg==", "dev": true, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -1640,95 +1660,96 @@ } }, "node_modules/@volar/language-core": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.7.10.tgz", - "integrity": "sha512-18Gmth5M0UI3hDDqhZngjMnb6WCslcfglkOdepRIhGxRYe7xR7DRRzciisYDMZsvOQxDYme+uaohg0dKUxLV2Q==", + "version": "1.10.9", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.10.9.tgz", + "integrity": "sha512-QXHMX7CeXLqXwvC7nbr6iZ3zrqgKdJ9f6g1B211eZBnvaBki2ds0+Kz8cprUiulVuMQEPJNhDfuh8Vym1gxHRQ==", "dev": true, "dependencies": { - "@volar/source-map": "1.7.10" + "@volar/source-map": "1.10.9" } }, "node_modules/@volar/source-map": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.7.10.tgz", - "integrity": "sha512-FBpLEOKJpRxeh2nYbw1mTI5sZOPXYU8LlsCz6xuBY3yNtAizDTTIZtBHe1V8BaMpoSMgRysZe4gVxMEi3rDGVA==", + "version": "1.10.9", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.10.9.tgz", + "integrity": "sha512-ul8yGO9nCxy6UedVuo0VsfKMLZzr39N1rgbtnYTGP5C554EDcUix6K/HDurhVdPHEDIw1yhXltLZZQKi3NrTvA==", "dev": true, "dependencies": { "muggle-string": "^0.3.1" } }, "node_modules/@volar/typescript": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.7.10.tgz", - "integrity": "sha512-yqIov4wndLU3GE1iE25bU5W6T+P+exPePcE1dFPPBKzQIBki1KvmdQN5jBlJp3Wo+wp7UIxa/RsdNkXT+iFBjg==", + "version": "1.10.9", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.10.9.tgz", + "integrity": "sha512-5jLB46mCQLJqLII/qDLgfyHSq1cesjwuJQIa2GNWd7LPLSpX5vzo3jfQLWc/gyo3up2fQFrlRJK2kgY5REtwuQ==", "dev": true, "dependencies": { - "@volar/language-core": "1.7.10" + "@volar/language-core": "1.10.9", + "path-browserify": "^1.0.1" } }, "node_modules/@vue/compiler-core": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.4.tgz", - "integrity": "sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.7.tgz", + "integrity": "sha512-pACdY6YnTNVLXsB86YD8OF9ihwpolzhhtdLVHhBL6do/ykr6kKXNYABRtNMGrsQXpEXXyAdwvWWkuTbs4MFtPQ==", "dependencies": { - "@babel/parser": "^7.21.3", - "@vue/shared": "3.3.4", + "@babel/parser": "^7.23.0", + "@vue/shared": "3.3.7", "estree-walker": "^2.0.2", "source-map-js": "^1.0.2" } }, "node_modules/@vue/compiler-dom": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.4.tgz", - "integrity": "sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.7.tgz", + "integrity": "sha512-0LwkyJjnUPssXv/d1vNJ0PKfBlDoQs7n81CbO6Q0zdL7H1EzqYRrTVXDqdBVqro0aJjo/FOa1qBAPVI4PGSHBw==", "dependencies": { - "@vue/compiler-core": "3.3.4", - "@vue/shared": "3.3.4" + "@vue/compiler-core": "3.3.7", + "@vue/shared": "3.3.7" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.4.tgz", - "integrity": "sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==", - "dependencies": { - "@babel/parser": "^7.20.15", - "@vue/compiler-core": "3.3.4", - "@vue/compiler-dom": "3.3.4", - "@vue/compiler-ssr": "3.3.4", - "@vue/reactivity-transform": "3.3.4", - "@vue/shared": "3.3.4", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.7.tgz", + "integrity": "sha512-7pfldWy/J75U/ZyYIXRVqvLRw3vmfxDo2YLMwVtWVNew8Sm8d6wodM+OYFq4ll/UxfqVr0XKiVwti32PCrruAw==", + "dependencies": { + "@babel/parser": "^7.23.0", + "@vue/compiler-core": "3.3.7", + "@vue/compiler-dom": "3.3.7", + "@vue/compiler-ssr": "3.3.7", + "@vue/reactivity-transform": "3.3.7", + "@vue/shared": "3.3.7", "estree-walker": "^2.0.2", - "magic-string": "^0.30.0", - "postcss": "^8.1.10", + "magic-string": "^0.30.5", + "postcss": "^8.4.31", "source-map-js": "^1.0.2" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.4.tgz", - "integrity": "sha512-m0v6oKpup2nMSehwA6Uuu+j+wEwcy7QmwMkVNVfrV9P2qE5KshC6RwOCq8fjGS/Eak/uNb8AaWekfiXxbBB6gQ==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.7.tgz", + "integrity": "sha512-TxOfNVVeH3zgBc82kcUv+emNHo+vKnlRrkv8YvQU5+Y5LJGJwSNzcmLUoxD/dNzv0bhQ/F0s+InlgV0NrApJZg==", "dependencies": { - "@vue/compiler-dom": "3.3.4", - "@vue/shared": "3.3.4" + "@vue/compiler-dom": "3.3.7", + "@vue/shared": "3.3.7" } }, "node_modules/@vue/devtools-api": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.0.tgz", - "integrity": "sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q==" + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.1.tgz", + "integrity": "sha512-+KpckaAQyfbvshdDW5xQylLni1asvNSGme1JFs8I1+/H5pHEhqUKMEQD/qn3Nx5+/nycBq11qAEi8lk+LXI2dA==" }, "node_modules/@vue/language-core": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.3.tgz", - "integrity": "sha512-AzhvMYoQkK/tg8CpAAttO19kx1zjS3+weYIr2AhlH/M5HebVzfftQoq4jZNFifjq+hyLKi8j9FiDMS8oqA89+A==", + "version": "1.8.22", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.22.tgz", + "integrity": "sha512-bsMoJzCrXZqGsxawtUea1cLjUT9dZnDsy5TuZ+l1fxRMzUGQUG9+Ypq4w//CqpWmrx7nIAJpw2JVF/t258miRw==", "dev": true, "dependencies": { - "@volar/language-core": "1.7.10", - "@volar/source-map": "1.7.10", + "@volar/language-core": "~1.10.5", + "@volar/source-map": "~1.10.5", "@vue/compiler-dom": "^3.3.0", - "@vue/reactivity": "^3.3.0", "@vue/shared": "^3.3.0", - "minimatch": "^9.0.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", "muggle-string": "^0.3.1", "vue-template-compiler": "^2.7.14" }, @@ -1751,9 +1772,9 @@ } }, "node_modules/@vue/language-core/node_modules/minimatch": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.2.tgz", - "integrity": "sha512-PZOT9g5v2ojiTL7r1xF6plNHLtOeTpSlDI007As2NlA2aYBMfVom17yqa6QzhmDP8QOhn7LjHTg7DFCVSSa6yg==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" @@ -1766,75 +1787,65 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.4.tgz", - "integrity": "sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.7.tgz", + "integrity": "sha512-cZNVjWiw00708WqT0zRpyAgduG79dScKEPYJXq2xj/aMtk3SKvL3FBt2QKUlh6EHBJ1m8RhBY+ikBUzwc7/khg==", "dependencies": { - "@vue/shared": "3.3.4" + "@vue/shared": "3.3.7" } }, "node_modules/@vue/reactivity-transform": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.4.tgz", - "integrity": "sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.7.tgz", + "integrity": "sha512-APhRmLVbgE1VPGtoLQoWBJEaQk4V8JUsqrQihImVqKT+8U6Qi3t5ATcg4Y9wGAPb3kIhetpufyZ1RhwbZCIdDA==", "dependencies": { - "@babel/parser": "^7.20.15", - "@vue/compiler-core": "3.3.4", - "@vue/shared": "3.3.4", + "@babel/parser": "^7.23.0", + "@vue/compiler-core": "3.3.7", + "@vue/shared": "3.3.7", "estree-walker": "^2.0.2", - "magic-string": "^0.30.0" + "magic-string": "^0.30.5" } }, "node_modules/@vue/runtime-core": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.3.4.tgz", - "integrity": "sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.3.7.tgz", + "integrity": "sha512-LHq9du3ubLZFdK/BP0Ysy3zhHqRfBn80Uc+T5Hz3maFJBGhci1MafccnL3rpd5/3wVfRHAe6c+PnlO2PAavPTQ==", "dependencies": { - "@vue/reactivity": "3.3.4", - "@vue/shared": "3.3.4" + "@vue/reactivity": "3.3.7", + "@vue/shared": "3.3.7" } }, "node_modules/@vue/runtime-dom": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.3.4.tgz", - "integrity": "sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.3.7.tgz", + "integrity": "sha512-PFQU1oeJxikdDmrfoNQay5nD4tcPNYixUBruZzVX/l0eyZvFKElZUjW4KctCcs52nnpMGO6UDK+jF5oV4GT5Lw==", "dependencies": { - "@vue/runtime-core": "3.3.4", - "@vue/shared": "3.3.4", - "csstype": "^3.1.1" + "@vue/runtime-core": "3.3.7", + "@vue/shared": "3.3.7", + "csstype": "^3.1.2" } }, "node_modules/@vue/server-renderer": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.3.4.tgz", - "integrity": "sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.3.7.tgz", + "integrity": "sha512-UlpKDInd1hIZiNuVVVvLgxpfnSouxKQOSE2bOfQpBuGwxRV/JqqTCyyjXUWiwtVMyeRaZhOYYqntxElk8FhBhw==", "dependencies": { - "@vue/compiler-ssr": "3.3.4", - "@vue/shared": "3.3.4" + "@vue/compiler-ssr": "3.3.7", + "@vue/shared": "3.3.7" }, "peerDependencies": { - "vue": "3.3.4" + "vue": "3.3.7" } }, "node_modules/@vue/shared": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.4.tgz", - "integrity": "sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==" - }, - "node_modules/@vue/typescript": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/@vue/typescript/-/typescript-1.8.3.tgz", - "integrity": "sha512-6bdgSnIFpRYHlt70pHmnmNksPU00bfXgqAISeaNz3W6d2cH0OTfH8h/IhligQ82sJIhsuyfftQJ5518ZuKIhtA==", - "dev": true, - "dependencies": { - "@volar/typescript": "1.7.10", - "@vue/language-core": "1.8.3" - } + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.7.tgz", + "integrity": "sha512-N/tbkINRUDExgcPTBvxNkvHGu504k8lzlNQRITVnm6YjOjwa4r0nnbd4Jb01sNpur5hAllyRJzSK5PvB9PPwRg==" }, "node_modules/acorn": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz", - "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", + "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", "devOptional": true, "bin": { "acorn": "bin/acorn" @@ -1853,9 +1864,9 @@ } }, "node_modules/acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.0.tgz", + "integrity": "sha512-FS7hV565M5l1R08MXqo8odwMTB02C2UqzB17RVgu9EyuYFBqJZ3/ZY97sQD5FewVu1UyDFc1yztUDrAwT0EypA==", "devOptional": true, "engines": { "node": ">=0.4.0" @@ -1993,9 +2004,9 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/autoprefixer": { - "version": "10.4.14", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", - "integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==", + "version": "10.4.16", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", + "integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==", "dev": true, "funding": [ { @@ -2005,12 +2016,16 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "dependencies": { - "browserslist": "^4.21.5", - "caniuse-lite": "^1.0.30001464", - "fraction.js": "^4.2.0", + "browserslist": "^4.21.10", + "caniuse-lite": "^1.0.30001538", + "fraction.js": "^4.3.6", "normalize-range": "^0.1.2", "picocolors": "^1.0.0", "postcss-value-parser": "^4.2.0" @@ -2026,9 +2041,9 @@ } }, "node_modules/axios": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", - "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", + "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -2100,9 +2115,9 @@ "dev": true }, "node_modules/browserslist": { - "version": "4.21.9", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.9.tgz", - "integrity": "sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", + "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", "dev": true, "funding": [ { @@ -2119,10 +2134,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001503", - "electron-to-chromium": "^1.4.431", - "node-releases": "^2.0.12", - "update-browserslist-db": "^1.0.11" + "caniuse-lite": "^1.0.30001541", + "electron-to-chromium": "^1.4.535", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.13" }, "bin": { "browserslist": "cli.js" @@ -2173,9 +2188,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001509", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001509.tgz", - "integrity": "sha512-2uDDk+TRiTX5hMcUYT/7CSyzMZxjfGu0vAUjS2g0LSD8UoXOv0LtpH4LxGMemsiPq6LCVIUjNwVM0erkOkGCDA==", + "version": "1.0.30001559", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001559.tgz", + "integrity": "sha512-cPiMKZgqgkg5LY3/ntGeLFUpi6tzddBNS58A4tnTgQw1zON7u2sZMU7SzOeVH4tj20++9ggL+V6FDOFMTaFFYA==", "dev": true, "funding": [ { @@ -2201,18 +2216,18 @@ } }, "node_modules/chai": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", - "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==", + "version": "4.3.10", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", + "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==", "dev": true, "dependencies": { "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^4.1.2", - "get-func-name": "^2.0.0", - "loupe": "^2.3.1", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", "pathval": "^1.1.1", - "type-detect": "^4.0.5" + "type-detect": "^4.0.8" }, "engines": { "node": ">=4" @@ -2259,10 +2274,13 @@ } }, "node_modules/check-error": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, "engines": { "node": "*" } @@ -2367,6 +2385,12 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2536,9 +2560,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.445", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.445.tgz", - "integrity": "sha512-++DB+9VK8SBJwC+X1zlMfJ1tMA3F0ipi39GdEp+x3cV2TyBihqAgad8cNMWtLDEkbH39nlDQP7PfGrDr3Dr7HA==", + "version": "1.4.571", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.571.tgz", + "integrity": "sha512-Sc+VtKwKCDj3f/kLBjdyjMpNzoZsU6WuL/wFb6EH8USmHEcebxRXcRrVpOpayxd52tuey4RUDpUsw5OS5LhJqg==", "dev": true }, "node_modules/emoji-regex": { @@ -2554,9 +2578,9 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", - "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", "dev": true, "hasInstallScript": true, "bin": { @@ -2566,28 +2590,28 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/android-arm": "0.17.19", - "@esbuild/android-arm64": "0.17.19", - "@esbuild/android-x64": "0.17.19", - "@esbuild/darwin-arm64": "0.17.19", - "@esbuild/darwin-x64": "0.17.19", - "@esbuild/freebsd-arm64": "0.17.19", - "@esbuild/freebsd-x64": "0.17.19", - "@esbuild/linux-arm": "0.17.19", - "@esbuild/linux-arm64": "0.17.19", - "@esbuild/linux-ia32": "0.17.19", - "@esbuild/linux-loong64": "0.17.19", - "@esbuild/linux-mips64el": "0.17.19", - "@esbuild/linux-ppc64": "0.17.19", - "@esbuild/linux-riscv64": "0.17.19", - "@esbuild/linux-s390x": "0.17.19", - "@esbuild/linux-x64": "0.17.19", - "@esbuild/netbsd-x64": "0.17.19", - "@esbuild/openbsd-x64": "0.17.19", - "@esbuild/sunos-x64": "0.17.19", - "@esbuild/win32-arm64": "0.17.19", - "@esbuild/win32-ia32": "0.17.19", - "@esbuild/win32-x64": "0.17.19" + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" } }, "node_modules/escalade": { @@ -2633,27 +2657,28 @@ } }, "node_modules/eslint": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.43.0.tgz", - "integrity": "sha512-aaCpf2JqqKesMFGgmRPessmVKjcGXqdlAYLLC3THM8t5nBRZRQ+st5WM/hoJXkdioEXLLbXgclUpM0TXo5HX5Q==", + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.52.0.tgz", + "integrity": "sha512-zh/JHnaixqHZsolRB/w9/02akBk9EPrOs9JwcTP2ek7yL5bVvXuRariiaAjjoJ5DvuwQ1WAE/HsMz+w17YgBCg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.0.3", - "@eslint/js": "8.43.0", - "@humanwhocodes/config-array": "^0.11.10", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.2", + "@eslint/js": "8.52.0", + "@humanwhocodes/config-array": "^0.11.13", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.0", - "eslint-visitor-keys": "^3.4.1", - "espree": "^9.5.2", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -2663,7 +2688,6 @@ "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", - "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", @@ -2673,9 +2697,8 @@ "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.1", + "optionator": "^0.9.3", "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" }, "bin": { @@ -2689,17 +2712,17 @@ } }, "node_modules/eslint-plugin-vue": { - "version": "9.15.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.15.1.tgz", - "integrity": "sha512-CJE/oZOslvmAR9hf8SClTdQ9JLweghT6JCBQNrT2Iel1uVw0W0OLJxzvPd6CxmABKCvLrtyDnqGV37O7KQv6+A==", + "version": "9.18.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.18.1.tgz", + "integrity": "sha512-7hZFlrEgg9NIzuVik2I9xSnJA5RsmOfueYgsUGUokEDLJ1LHtxO0Pl4duje1BriZ/jDWb+44tcIlC3yi0tdlZg==", "dev": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.3.0", + "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", - "nth-check": "^2.0.1", - "postcss-selector-parser": "^6.0.9", - "semver": "^7.3.5", - "vue-eslint-parser": "^9.3.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^6.0.13", + "semver": "^7.5.4", + "vue-eslint-parser": "^9.3.1", "xml-name-validator": "^4.0.0" }, "engines": { @@ -2710,56 +2733,14 @@ } }, "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "dependencies": { "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/eslint-scope/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/eslint-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", - "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^1.1.0" - }, - "engines": { - "node": ">=6" + "estraverse": "^5.2.0" }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, - "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", - "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", - "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -2767,15 +2748,11 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/eslint-scope": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", - "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -2783,30 +2760,13 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", - "dev": true, - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/espree": { - "version": "9.5.2", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz", - "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "dependencies": { - "acorn": "^8.8.0", + "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" }, @@ -2884,9 +2844,9 @@ "dev": true }, "node_modules/fast-glob": { - "version": "3.2.12", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", - "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -3000,28 +2960,29 @@ } }, "node_modules/flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz", + "integrity": "sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q==", "dev": true, "dependencies": { - "flatted": "^3.1.0", + "flatted": "^3.2.9", + "keyv": "^4.5.3", "rimraf": "^3.0.2" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=12.0.0" } }, "node_modules/flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", + "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", "funding": [ { "type": "individual", @@ -3064,16 +3025,16 @@ } }, "node_modules/fraction.js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", - "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", "dev": true, "engines": { "node": "*" }, "funding": { "type": "patreon", - "url": "https://www.patreon.com/infusion" + "url": "https://github.com/sponsors/rawify" } }, "node_modules/fromentries": { @@ -3102,9 +3063,9 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "hasInstallScript": true, "optional": true, "os": [ @@ -3115,9 +3076,12 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/gensync": { "version": "1.0.0-beta.2", @@ -3138,9 +3102,9 @@ } }, "node_modules/get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "dev": true, "engines": { "node": "*" @@ -3187,9 +3151,9 @@ } }, "node_modules/globals": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", - "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "version": "13.23.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", + "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", "dev": true, "dependencies": { "type-fest": "^0.20.2" @@ -3227,29 +3191,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, - "node_modules/grapheme-splitter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", - "dev": true - }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3284,6 +3231,17 @@ "node": ">=8" } }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -3373,11 +3331,11 @@ } }, "node_modules/is-core-module": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz", - "integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3524,9 +3482,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -3550,17 +3508,32 @@ } }, "node_modules/istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, "dependencies": { "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^3.0.0", + "make-dir": "^4.0.0", "supports-color": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/istanbul-lib-source-maps": { @@ -3578,9 +3551,9 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", - "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", "dev": true, "dependencies": { "html-escaper": "^2.0.0", @@ -3605,9 +3578,9 @@ } }, "node_modules/jiti": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz", - "integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", "bin": { "jiti": "bin/jiti.js" } @@ -3647,6 +3620,12 @@ "node": ">=4" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3672,63 +3651,21 @@ } }, "node_modules/jsonc-eslint-parser": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-1.4.1.tgz", - "integrity": "sha512-hXBrvsR1rdjmB2kQmUjf1rEIa+TqHBGMge8pwi++C+Si1ad7EjZrJcpgwym+QGK/pqTx+K7keFAtLlVNdLRJOg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.0.tgz", + "integrity": "sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==", "dev": true, "dependencies": { - "acorn": "^7.4.1", - "eslint-utils": "^2.1.0", - "eslint-visitor-keys": "^1.3.0", - "espree": "^6.0.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/jsonc-eslint-parser/node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true, - "bin": { - "acorn": "bin/acorn" + "acorn": "^8.5.0", + "eslint-visitor-keys": "^3.0.0", + "espree": "^9.0.0", + "semver": "^7.3.5" }, "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/jsonc-eslint-parser/node_modules/eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/jsonc-eslint-parser/node_modules/espree": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-6.2.1.tgz", - "integrity": "sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==", - "dev": true, - "dependencies": { - "acorn": "^7.1.1", - "acorn-jsx": "^5.2.0", - "eslint-visitor-keys": "^1.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/jsonc-eslint-parser/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true, - "bin": { - "semver": "bin/semver.js" + "funding": { + "url": "https://github.com/sponsors/ota-meshi" } }, "node_modules/jsonc-parser": { @@ -3749,14 +3686,23 @@ } }, "node_modules/keycloak-js": { - "version": "22.0.1", - "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-22.0.1.tgz", - "integrity": "sha512-5cwOzMTMW2HuKGaIHv50BJHz2o8ID+YgzaaXKNwOk0XqD6ZOPD/jQXvqTz+Z8ID5cP46zVWnNiTouFK41NbPOQ==", + "version": "22.0.5", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-22.0.5.tgz", + "integrity": "sha512-a7ZwCZeHl8tpeJBy102tZtAnHslDUOA1Nf/sHNF3HYLchKpwoDuaitwIUiS2GnNUe+tlNKLlCqZS+Mi5K79m1w==", "dependencies": { "base64-js": "^1.5.1", "js-sha256": "^0.9.0" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3841,12 +3787,12 @@ } }, "node_modules/loupe": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", - "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", "dev": true, "dependencies": { - "get-func-name": "^2.0.0" + "get-func-name": "^2.0.1" } }, "node_modules/lru-cache": { @@ -3859,11 +3805,11 @@ } }, "node_modules/magic-string": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz", - "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==", + "version": "0.30.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", + "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.13" + "@jridgewell/sourcemap-codec": "^1.4.15" }, "engines": { "node": ">=12" @@ -3885,9 +3831,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -3964,15 +3910,15 @@ "integrity": "sha512-fL9KxsQz9BJB2KGPMHFrReioywkiomBiuaLk6EuChijK0BsJsIKJXdVomR+/bPj5mvbFD6wM0CM3bZio9g7OHA==" }, "node_modules/mlly": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.4.0.tgz", - "integrity": "sha512-ua8PAThnTwpprIaU47EPeZ/bPUVp2QYBbWMphUQpVdBI3Lgqzm5KZQ45Agm3YJedHXaIHl6pBGabaLSUPPSptg==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.4.2.tgz", + "integrity": "sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg==", "dev": true, "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.10.0", "pathe": "^1.1.1", "pkg-types": "^1.0.3", - "ufo": "^1.1.2" + "ufo": "^1.3.0" } }, "node_modules/mocha": { @@ -4097,12 +4043,6 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "node_modules/natural-compare-lite": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", - "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", - "dev": true - }, "node_modules/node-preload": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", @@ -4116,9 +4056,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.12.tgz", - "integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==", + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", "dev": true }, "node_modules/normalize-path": { @@ -4342,6 +4282,23 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, + "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4425,6 +4382,12 @@ "node": ">=6" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4588,9 +4551,9 @@ } }, "node_modules/postcss": { - "version": "8.4.24", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz", - "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "funding": [ { "type": "opencollective", @@ -4760,9 +4723,9 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "engines": { "node": ">=6" @@ -4857,11 +4820,11 @@ "dev": true }, "node_modules/resolve": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", - "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "dependencies": { - "is-core-module": "^2.11.0", + "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -4891,9 +4854,9 @@ } }, "node_modules/rfc4648": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.2.tgz", - "integrity": "sha512-tLOizhR6YGovrEBLatX1sdcuhoSCXddw3mqNVAcKxGJ+J0hFeJ+SjeWCv5UPA/WU3YzWPPuCVYgXBKZUPGpKtg==" + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.3.tgz", + "integrity": "sha512-MjOWxM065+WswwnmNONOT+bD1nXzY9Km6u3kzvnx8F8/HXGZdz3T6e6vZJ8Q/RIMUSp/nxqjH3GwvJDy8ijeQQ==" }, "node_modules/rimraf": { "version": "3.0.2", @@ -4911,9 +4874,9 @@ } }, "node_modules/rollup": { - "version": "3.25.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.25.3.tgz", - "integrity": "sha512-ZT279hx8gszBj9uy5FfhoG4bZx8c+0A1sbqtr7Q3KNWIizpTdDEPZbV2xcbvHsnFp4MavCQYZyzApJ+virB8Yw==", + "version": "3.29.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", + "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", "dev": true, "bin": { "rollup": "dist/bin/rollup" @@ -4954,9 +4917,9 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -5043,6 +5006,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -5134,9 +5098,9 @@ } }, "node_modules/sucrase": { - "version": "3.32.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.32.0.tgz", - "integrity": "sha512-ydQOU34rpSyj2TGyz4D2p8rbktIOZ8QY9s+DGLvFU1i5pWJE8vkpruCjGCMHsdXwnD7JDcS+noSwM/a7zyNFDQ==", + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", + "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", @@ -5197,19 +5161,19 @@ } }, "node_modules/tailwindcss": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", - "integrity": "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz", + "integrity": "sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==", "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", - "fast-glob": "^3.2.12", + "fast-glob": "^3.3.0", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.18.2", + "jiti": "^1.19.1", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", @@ -5221,7 +5185,6 @@ "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", - "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, @@ -5292,6 +5255,18 @@ "node": ">=8.0" } }, + "node_modules/ts-api-utils": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", + "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", + "dev": true, + "engines": { + "node": ">=16.13.0" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -5355,27 +5330,6 @@ "node": ">=0.3.1" } }, - "node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5419,9 +5373,9 @@ } }, "node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "devOptional": true, "bin": { "tsc": "bin/tsc", @@ -5432,27 +5386,32 @@ } }, "node_modules/ufo": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.1.2.tgz", - "integrity": "sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.1.tgz", + "integrity": "sha512-uY/99gMLIOlJPwATcMVYfqDSxUR9//AUcgZMzwfSTJPDKzA1S8mX4VLqa+fiAtveraQUBCz4FFcwVZBGbwBXIw==", "dev": true }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, "node_modules/unplugin": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.3.1.tgz", - "integrity": "sha512-h4uUTIvFBQRxUKS2Wjys6ivoeofGhxzTe2sRWlooyjHXVttcVfV/JiavNd3d4+jty0SVV0dxGw9AkY9MwiaCEw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.5.0.tgz", + "integrity": "sha512-9ZdRwbh/4gcm1JTOkp9lAkIDrtOyOxgHmY7cjuwI8L/2RTikMcVG25GsZwNAgRuap3iDw2jeq7eoqtAsz5rW3A==", "dev": true, "dependencies": { - "acorn": "^8.8.2", + "acorn": "^8.10.0", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "node_modules/update-browserslist-db": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", - "integrity": "sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", "dev": true, "funding": [ { @@ -5509,14 +5468,14 @@ "devOptional": true }, "node_modules/vite": { - "version": "4.3.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.3.9.tgz", - "integrity": "sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz", + "integrity": "sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==", "dev": true, "dependencies": { - "esbuild": "^0.17.5", - "postcss": "^8.4.23", - "rollup": "^3.21.0" + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" }, "bin": { "vite": "bin/vite.js" @@ -5524,12 +5483,16 @@ "engines": { "node": "^14.18.0 || >=16.0.0" }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@types/node": ">= 14", "less": "*", + "lightningcss": "^1.21.0", "sass": "*", "stylus": "*", "sugarss": "*", @@ -5542,6 +5505,9 @@ "less": { "optional": true }, + "lightningcss": { + "optional": true + }, "sass": { "optional": true }, @@ -5557,21 +5523,29 @@ } }, "node_modules/vue": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.3.4.tgz", - "integrity": "sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.3.7.tgz", + "integrity": "sha512-YEMDia1ZTv1TeBbnu6VybatmSteGOS3A3YgfINOfraCbf85wdKHzscD6HSS/vB4GAtI7sa1XPX7HcQaJ1l24zA==", "dependencies": { - "@vue/compiler-dom": "3.3.4", - "@vue/compiler-sfc": "3.3.4", - "@vue/runtime-dom": "3.3.4", - "@vue/server-renderer": "3.3.4", - "@vue/shared": "3.3.4" + "@vue/compiler-dom": "3.3.7", + "@vue/compiler-sfc": "3.3.7", + "@vue/runtime-dom": "3.3.7", + "@vue/server-renderer": "3.3.7", + "@vue/shared": "3.3.7" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/vue-eslint-parser": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.3.1.tgz", - "integrity": "sha512-Clr85iD2XFZ3lJ52/ppmUDG/spxQu6+MAeHXjjyI4I1NUYZ9xmenQp4N0oaHJhrA8OOxltCVxMRfANGa70vU0g==", + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.3.2.tgz", + "integrity": "sha512-q7tWyCVaV9f8iQyIA5Mkj/S6AoJ9KBN8IeUSf3XEmBrOtxOZnfTg5s4KClbZBCK3GtnT/+RyCLZyDHuZwTuBjg==", "dev": true, "dependencies": { "debug": "^4.3.4", @@ -5592,51 +5566,29 @@ "eslint": ">=6.0.0" } }, - "node_modules/vue-eslint-parser/node_modules/eslint-scope": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", - "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", - "dev": true, + "node_modules/vue-i18n": { + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.6.2.tgz", + "integrity": "sha512-J43grTQjPR8LCUxvx3mkoM+11xhTnej1Al4lvJCEeKmQqf8eqbuYPQb54HXnEg/UzZyaxLBAwPAUTbrZ8V7hcg==", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "@intlify/core-base": "9.6.2", + "@intlify/shared": "9.6.2", + "@vue/devtools-api": "^6.5.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">= 16" }, "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/vue-i18n": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.2.2.tgz", - "integrity": "sha512-yswpwtj89rTBhegUAv9Mu37LNznyu3NpyLQmozF3i1hYOhwpG8RjcjIFIIfnu+2MDZJGSZPXaKWvnQA71Yv9TQ==", - "dependencies": { - "@intlify/core-base": "9.2.2", - "@intlify/shared": "9.2.2", - "@intlify/vue-devtools": "9.2.2", - "@vue/devtools-api": "^6.2.1" - }, - "engines": { - "node": ">= 14" + "url": "https://github.com/sponsors/kazupon" }, "peerDependencies": { "vue": "^3.0.0" } }, - "node_modules/vue-i18n/node_modules/@intlify/shared": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.2.2.tgz", - "integrity": "sha512-wRwTpsslgZS5HNyM7uDQYZtxnbI12aGiBZURX3BTR9RFIKKRWpllTsgzHWvj3HKm3Y2Sh5LPC1r0PDCKEhVn9Q==", - "engines": { - "node": ">= 14" - } - }, "node_modules/vue-router": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.2.tgz", - "integrity": "sha512-cChBPPmAflgBGmy3tBsjeoe3f3VOSG6naKyY5pjtrqLGbNEXdzCigFUHgBvp9e3ysAtFtEx7OLqcSDh/1Cq2TQ==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.5.tgz", + "integrity": "sha512-DIUpKcyg4+PTQKfFPX88UWhlagBEBEfJ5A8XDXRJLUnZOvcpMF8o/dnL90vpVkGaPbjvXazV/rC1qBKrZlFugw==", "dependencies": { "@vue/devtools-api": "^6.5.0" }, @@ -5648,9 +5600,9 @@ } }, "node_modules/vue-template-compiler": { - "version": "2.7.14", - "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.14.tgz", - "integrity": "sha512-zyA5Y3ArvVG0NacJDkkzJuPQDF8RFeRlzV2vLeSnhSpieO6LK2OVbdLPi5MPPs09Ii+gMO8nY4S3iKQxBxDmWQ==", + "version": "2.7.15", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.15.tgz", + "integrity": "sha512-yQxjxMptBL7UAog00O8sANud99C6wJF+7kgbcwqkvA38vCGF7HWE66w0ZFnS/kX5gSoJr/PQ4/oS3Ne2pW37Og==", "dev": true, "dependencies": { "de-indent": "^1.0.2", @@ -5658,14 +5610,14 @@ } }, "node_modules/vue-tsc": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.3.tgz", - "integrity": "sha512-Ua4DHuYxjudlhCW2nRZtaXbhIDVncRGIbDjZhHpF8Z8vklct/G/35/kAPuGNSOmq0JcvhPAe28Oa7LWaUerZVA==", + "version": "1.8.22", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.22.tgz", + "integrity": "sha512-j9P4kHtW6eEE08aS5McFZE/ivmipXy0JzrnTgbomfABMaVKx37kNBw//irL3+LlE3kOo63XpnRigyPC3w7+z+A==", "dev": true, "dependencies": { - "@vue/language-core": "1.8.3", - "@vue/typescript": "1.8.3", - "semver": "^7.3.8" + "@volar/typescript": "~1.10.5", + "@vue/language-core": "1.8.22", + "semver": "^7.5.4" }, "bin": { "vue-tsc": "bin/vue-tsc.js" @@ -5775,40 +5727,28 @@ "dev": true }, "node_modules/yaml": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", - "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.3.tgz", + "integrity": "sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ==", "engines": { "node": ">= 14" } }, "node_modules/yaml-eslint-parser": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-0.3.2.tgz", - "integrity": "sha512-32kYO6kJUuZzqte82t4M/gB6/+11WAuHiEnK7FreMo20xsCKPeFH5tDBU7iWxR7zeJpNnMXfJyXwne48D0hGrg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.2.2.tgz", + "integrity": "sha512-pEwzfsKbTrB8G3xc/sN7aw1v6A6c/pKxLAkjclnAyo5g5qOh6eL9WGu0o3cSDQZKrTNk4KL4lQSwZW+nBkANEg==", "dev": true, "dependencies": { - "eslint-visitor-keys": "^1.3.0", - "lodash": "^4.17.20", - "yaml": "^1.10.0" - } - }, - "node_modules/yaml-eslint-parser/node_modules/eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/yaml-eslint-parser/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, + "eslint-visitor-keys": "^3.0.0", + "lodash": "^4.17.21", + "yaml": "^2.0.0" + }, "engines": { - "node": ">= 6" + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" } }, "node_modules/yargs": { diff --git a/frontend/package.json b/frontend/package.json index 98d2cb70d..48c865a69 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "cryptomator-hub", - "version": "1.2.2", + "version": "1.3.0", "description": "Web-Frontend for Cryptomator Hub", "author": "Skymatic GmbH", "license": "AGPL-3.0-or-later", @@ -19,48 +19,48 @@ "test": "./test" }, "devDependencies": { - "@intlify/unplugin-vue-i18n": "^0.11.0", - "@tailwindcss/forms": "^0.5.3", - "@types/blueimp-md5": "^2.18.0", - "@types/chai": "^4.3.5", - "@types/chai-as-promised": "^7.1.5", - "@types/file-saver": "^2.0.5", - "@types/mocha": "^10.0.1", - "@types/node": "^20.3.2", - "@types/semver": "^7.5.0", - "@typescript-eslint/eslint-plugin": "^5.60.1", - "@typescript-eslint/parser": "^5.60.1", - "@vitejs/plugin-vue": "^4.2.3", + "@intlify/unplugin-vue-i18n": "^1.4.0", + "@tailwindcss/forms": "^0.5.6", + "@types/blueimp-md5": "^2.18.1", + "@types/chai": "^4.3.9", + "@types/chai-as-promised": "^7.1.7", + "@types/file-saver": "^2.0.6", + "@types/mocha": "^10.0.3", + "@types/node": "^20.8.10", + "@types/semver": "^7.5.4", + "@typescript-eslint/eslint-plugin": "^6.9.1", + "@typescript-eslint/parser": "^6.9.1", + "@vitejs/plugin-vue": "^4.4.0", "@vue/compiler-sfc": "^3.3.4", - "autoprefixer": "^10.4.14", - "chai": "^4.3.7", + "autoprefixer": "^10.4.16", + "chai": "^4.3.10", "chai-as-promised": "^7.1.1", "chai-bytes": "^0.1.2", - "eslint": "^8.43.0", - "eslint-plugin-vue": "^9.15.1", + "eslint": "^8.52.0", + "eslint-plugin-vue": "^9.18.1", "mocha": "^10.2.0", "nyc": "^15.1.0", - "postcss": "^8.4.24", - "tailwindcss": "^3.3.2", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", "ts-node": "^10.9.1", - "typescript": "^5.1.6", - "vite": "^4.3.9", - "vue-tsc": "^1.8.3" + "typescript": "^5.2.2", + "vite": "^4.5.0", + "vue-tsc": "^1.8.22" }, "dependencies": { - "@headlessui/tailwindcss": "^0.1.3", - "@headlessui/vue": "^1.7.14", + "@headlessui/tailwindcss": "^0.2.0", + "@headlessui/vue": "^1.7.16", "@heroicons/vue": "^2.0.18", - "axios": "^1.4.0", + "axios": "^1.6.0", "file-saver": "^2.0.5", "jdenticon": "^3.2.0", "jszip": "^3.10.1", - "keycloak-js": "^22.0.1", + "keycloak-js": "^22.0.5", "miscreant": "^0.3.2", - "rfc4648": "^1.5.2", - "semver": "^7.5.3", + "rfc4648": "^1.5.3", + "semver": "^7.5.4", "vue": "^3.3.4", - "vue-i18n": "^9.2.2", - "vue-router": "^4.2.2" + "vue-i18n": "^9.6.2", + "vue-router": "^4.2.5" } } diff --git a/frontend/src/common/auditlog.ts b/frontend/src/common/auditlog.ts index 3c3b1c3e2..e6c83dcc6 100644 --- a/frontend/src/common/auditlog.ts +++ b/frontend/src/common/auditlog.ts @@ -6,7 +6,7 @@ import { Deferred, debounce } from './util'; export type AuditEventDto = { id: number; timestamp: Date; - type: 'DEVICE_REGISTER' | 'DEVICE_REMOVE' | 'VAULT_CREATE' | 'VAULT_UPDATE' | 'VAULT_ACCESS_GRANT' | 'VAULT_KEY_RETRIEVE' | 'VAULT_MEMBER_ADD' | 'VAULT_MEMBER_REMOVE'; + type: 'DEVICE_REGISTER' | 'DEVICE_REMOVE' | 'VAULT_CREATE' | 'VAULT_UPDATE' | 'VAULT_ACCESS_GRANT' | 'VAULT_KEY_RETRIEVE' | 'VAULT_MEMBER_ADD' | 'VAULT_MEMBER_REMOVE' | 'VAULT_MEMBER_UPDATE' | 'VAULT_OWNERSHIP_CLAIM'; } export type AuditEventDeviceRegisterDto = AuditEventDto & { @@ -52,6 +52,7 @@ export type AuditEventVaultMemberAddDto = AuditEventDto & { addedBy: string; vaultId: string; authorityId: string; + role: 'MEMBER' | 'OWNER'; } export type AuditEventVaultMemberRemoveDto = AuditEventDto & { @@ -60,6 +61,18 @@ export type AuditEventVaultMemberRemoveDto = AuditEventDto & { authorityId: string; } +export type AuditEventVaultMemberUpdateDto = AuditEventDto & { + updatedBy: string; + vaultId: string; + authorityId: string; + role: 'MEMBER' | 'OWNER'; +} + +export type AuditEventVaultOwnershipClaimDto = AuditEventDto & { + claimedBy: string; + vaultId: string; +} + /* Entity Cache */ export class AuditLogEntityCache { diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index b21b082cd..3ca473623 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -1,9 +1,8 @@ -import AxiosStatic, { AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse } from 'axios'; +import AxiosStatic, { AxiosHeaders, AxiosRequestConfig, AxiosResponse } from 'axios'; import { JdenticonConfig, toSvg } from 'jdenticon'; import { base64 } from 'rfc4648'; import authPromise from './auth'; -import config, { backendBaseURL } from './config'; -import { VaultKeys } from './crypto'; +import { backendBaseURL } from './config'; import { JWTHeader } from './jwt'; const axiosBaseCfg: AxiosRequestConfig = { @@ -23,9 +22,9 @@ axiosAuth.interceptors.request.use(async request => { try { const token = await authPromise.then(auth => auth.bearerToken()); if (request.headers) { - request.headers['Authorization'] = `Bearer ${token}`; + request.headers.setAuthorization(`Bearer ${token}`); } else { - request.headers = { 'Authorization': `Bearer ${token}` } as AxiosRequestHeaders; + request.headers = AxiosHeaders.from({ 'Authorization': `Bearer ${token}` }); } return request; } catch (err: unknown) { @@ -39,14 +38,14 @@ axiosAuth.interceptors.request.use(async request => { export type VaultDto = { id: string; name: string; - description: string; + description?: string; archived: boolean; creationTime: Date; - masterkey: string; - iterations: number; - salt: string; - authPublicKey: string; - authPrivateKey: string; + masterkey?: string; + iterations?: number; + salt?: string; + authPublicKey?: string; + authPrivateKey?: string; }; export type DeviceDto = { @@ -54,10 +53,17 @@ export type DeviceDto = { name: string; type: 'BROWSER' | 'DESKTOP' | 'MOBILE'; publicKey: string; - accessTo: VaultDto[]; + userPrivateKey: string; creationTime: Date; }; +export type VaultRole = 'MEMBER' | 'OWNER'; + +export type AccessGrant = { + userId: string, + token: string +}; + enum AuthorityType { User = 'USER', Group = 'GROUP' @@ -84,11 +90,12 @@ export abstract class AuthorityDto { } export class UserDto extends AuthorityDto { - constructor(public id: string, public name: string, public type: AuthorityType, public email: string, public devices: DeviceDto[], pictureUrl?: string) { + constructor(public id: string, public name: string, public type: AuthorityType, public email: string, public devices: DeviceDto[], public accessibleVaults: VaultDto[], pictureUrl?: string, + public publicKey?: string, public privateKey?: string, public setupCode?: string) { super(id, name, type, pictureUrl); } - getIdenticonConfig(): JdenticonConfig { + static getIdenticonConfig(): JdenticonConfig { return { hues: [6, 28, 48, 121, 283], saturation: { @@ -103,6 +110,10 @@ export class UserDto extends AuthorityDto { }; } + getIdenticonConfig(): JdenticonConfig { + return UserDto.getIdenticonConfig(); + } + static typeOf(obj: any): obj is UserDto { const userDto = obj as UserDto; return typeof userDto.id === 'string' @@ -112,7 +123,7 @@ export class UserDto extends AuthorityDto { } static copy(obj: UserDto): UserDto { - return new UserDto(obj.id, obj.name, obj.type, obj.email, obj.devices, obj.pictureUrl); + return new UserDto(obj.id, obj.name, obj.type, obj.email, obj.devices, obj.accessibleVaults, obj.pictureUrl, obj.publicKey, obj.privateKey, obj.setupCode); } } @@ -121,7 +132,7 @@ export class GroupDto extends AuthorityDto { super(id, name, type, pictureUrl); } - getIdenticonConfig(): JdenticonConfig { + static getIdenticonConfig(): JdenticonConfig { return { hues: [190], saturation: { @@ -136,6 +147,10 @@ export class GroupDto extends AuthorityDto { }; } + getIdenticonConfig(): JdenticonConfig { + return GroupDto.getIdenticonConfig(); + } + static typeOf(obj: any): obj is GroupDto { const groupDto = obj as GroupDto; return typeof groupDto.id === 'string' @@ -149,6 +164,21 @@ export class GroupDto extends AuthorityDto { } } +export class MemberDto extends AuthorityDto { + constructor(public id: string, public name: string, public type: AuthorityType, public role: VaultRole, pictureUrl: string) { + super(id, name, type, pictureUrl); + } + + getIdenticonConfig(): JdenticonConfig { + switch (this.type) { + case AuthorityType.User: + return UserDto.getIdenticonConfig(); + case AuthorityType.Group: + return GroupDto.getIdenticonConfig(); + } + } +} + export type BillingDto = { hubId: string; hasLicense: boolean; @@ -172,8 +202,9 @@ export interface VaultIdHeader extends JWTHeader { } class VaultService { - public async listAccessible(): Promise { - return axiosAuth.get('/vaults').then(response => response.data); + public async listAccessible(role?: 'MEMBER' | 'OWNER'): Promise { + const queryParams = role ? { role: role } : {}; + return axiosAuth.get('/vaults/accessible', { params: queryParams }).then(response => response.data); } public async listSome(vaultsIds: string[]): Promise { @@ -195,66 +226,62 @@ class VaultService { .catch((error) => rethrowAndConvertIfExpected(error, 404)); } - public async getMembers(vaultId: string, vaultKeys: VaultKeys): Promise<(UserDto | GroupDto)[]> { - let vaultAdminAuthorizationJWT = await this.buildVaultAdminAuthorizationJWT(vaultId, vaultKeys); - return axiosAuth.get<(UserDto | GroupDto)[]>(`/vaults/${vaultId}/members`, { headers: { 'Cryptomator-Vault-Admin-Authorization': vaultAdminAuthorizationJWT } }).then(response => { - return response.data.map(authority => { - if (UserDto.typeOf(authority)) { - return UserDto.copy(authority); - } else if (GroupDto.typeOf(authority)) { - return GroupDto.copy(authority); - } else { - throw new Error('Provided data is not of type UserDTO or GroupDTO'); - } - }); + public async getMembers(vaultId: string): Promise { + return axiosAuth.get(`/vaults/${vaultId}/members`).then(response => { + return response.data.map(member => new MemberDto(member.id, member.name, member.type, member.role, member.pictureUrl)); }).catch(err => rethrowAndConvertIfExpected(err, 403)); } - public async addUser(vaultId: string, userId: string, vaultKeys: VaultKeys): Promise> { - let vaultAdminAuthorizationJWT = await this.buildVaultAdminAuthorizationJWT(vaultId, vaultKeys); - return axiosAuth.put(`/vaults/${vaultId}/users/${userId}`, null, { headers: { 'Cryptomator-Vault-Admin-Authorization': vaultAdminAuthorizationJWT } }) + public async addUser(vaultId: string, userId: string, role?: VaultRole): Promise> { + return axiosAuth.put(`/vaults/${vaultId}/users/${userId}` + (role ? `?role=${role}` : '')) .catch((error) => rethrowAndConvertIfExpected(error, 402, 404, 409)); } - public async addGroup(vaultId: string, groupId: string, vaultKeys: VaultKeys): Promise> { - let vaultAdminAuthorizationJWT = await this.buildVaultAdminAuthorizationJWT(vaultId, vaultKeys); - return axiosAuth.put(`/vaults/${vaultId}/groups/${groupId}`, null, { headers: { 'Cryptomator-Vault-Admin-Authorization': vaultAdminAuthorizationJWT } }) + public async addGroup(vaultId: string, groupId: string, role?: VaultRole): Promise> { + return axiosAuth.put(`/vaults/${vaultId}/groups/${groupId}` + (role ? `?role=${role}` : '')) .catch((error) => rethrowAndConvertIfExpected(error, 402, 404, 409)); } - public async getDevicesRequiringAccessGrant(vaultId: string, vaultKeys: VaultKeys): Promise { - let vaultAdminAuthorizationJWT = await this.buildVaultAdminAuthorizationJWT(vaultId, vaultKeys); - return axiosAuth.get(`/vaults/${vaultId}/devices-requiring-access-grant`, { headers: { 'Cryptomator-Vault-Admin-Authorization': vaultAdminAuthorizationJWT } }) - .then(response => response.data).catch(err => rethrowAndConvertIfExpected(err, 403)); + public async getUsersRequiringAccessGrant(vaultId: string): Promise { + return axiosAuth.get(`/vaults/${vaultId}/users-requiring-access-grant`) + .then(response => { + return response.data.map(dto => UserDto.copy(dto)); + }) + .catch(err => rethrowAndConvertIfExpected(err, 403)); } - public async createOrUpdateVault(vaultId: string, name: string, description: string, archived: boolean, masterkey: string, iterations: number, salt: string, signPubKey: string, signPrvKey: string): Promise { - const body: VaultDto = { id: vaultId, name: name, description: description, archived: archived, creationTime: new Date(), masterkey: masterkey, iterations: iterations, salt: salt, authPublicKey: signPubKey, authPrivateKey: signPrvKey }; + public async createOrUpdateVault(vaultId: string, name: string, archived: boolean, description?: string): Promise { + const body: VaultDto = { id: vaultId, name: name, description: description, archived: archived, creationTime: new Date() }; return axiosAuth.put(`/vaults/${vaultId}`, body) .then(response => response.data) .catch((error) => rethrowAndConvertIfExpected(error, 402, 404)); } - public async grantAccess(vaultId: string, deviceId: string, jwe: string, vaultKeys: VaultKeys) { - let vaultAdminAuthorizationJWT = await this.buildVaultAdminAuthorizationJWT(vaultId, vaultKeys); - await axiosAuth.put(`/vaults/${vaultId}/keys/${deviceId}`, jwe, { headers: { 'Content-Type': 'text/plain', 'Cryptomator-Vault-Admin-Authorization': vaultAdminAuthorizationJWT } }) - .catch((error) => rethrowAndConvertIfExpected(error, 404, 409)); + public async claimOwnership(vaultId: string, proof: string): Promise { + const params = new URLSearchParams({ proof: proof }); + return axiosAuth.post(`/vaults/${vaultId}/claim-ownership`, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }) + .then(response => response.data) + .catch((error) => rethrowAndConvertIfExpected(error, 400, 404, 409)); } - public async revokeUserAccess(vaultId: string, userId: string, vaultKeys: VaultKeys) { - let vaultAdminAuthorizationJWT = await this.buildVaultAdminAuthorizationJWT(vaultId, vaultKeys); - await axiosAuth.delete(`/vaults/${vaultId}/users/${userId}`, { headers: { 'Cryptomator-Vault-Admin-Authorization': vaultAdminAuthorizationJWT } }) - .catch((error) => rethrowAndConvertIfExpected(error, 404)); + public async accessToken(vaultId: string, evenIfArchived = false): Promise { + return axiosAuth.get(`/vaults/${vaultId}/access-token?evenIfArchived=${evenIfArchived}`, { headers: { 'Content-Type': 'text/plain' } }) + .then(response => response.data) + .catch((error) => rethrowAndConvertIfExpected(error, 403)); } - private async buildVaultAdminAuthorizationJWT(vaultId: string, vaultKeys: VaultKeys): Promise { - let vaultIdHeader: VaultIdHeader = { alg: 'ES384', b64: true, typ: 'JWT', vaultId: vaultId }; - let jwtPayload = { iat: this.secondsSinceEpoch() + config.serverTimeDiff }; - return vaultKeys.signVaultEditRequest(vaultIdHeader, jwtPayload); + public async grantAccess(vaultId: string, ...grants: AccessGrant[]) { + var body = grants.reduce>((accumulator, curr) => { + accumulator[curr.userId] = curr.token; + return accumulator; + }, {}); + await axiosAuth.post(`/vaults/${vaultId}/access-tokens`, body) + .catch((error) => rethrowAndConvertIfExpected(error, 404, 409)); } - private secondsSinceEpoch(): number { - return Math.floor(Date.now() / 1000); + public async removeAuthority(vaultId: string, authorityId: string) { + await axiosAuth.delete(`/vaults/${vaultId}/authority/${authorityId}`) + .catch((error) => rethrowAndConvertIfExpected(error, 404)); } } class DeviceService { @@ -267,15 +294,23 @@ class DeviceService { return axiosAuth.delete(`/devices/${deviceId}`) .catch((error) => rethrowAndConvertIfExpected(error, 404)); } + + public async putDevice(device: DeviceDto): Promise> { + return axiosAuth.put(`/devices/${device.id}`, device); + } } class UserService { - public async syncMe(): Promise { - return axiosAuth.put('/users/me'); + public async putMe(dto?: UserDto): Promise { + return axiosAuth.put('/users/me', dto); + } + + public async me(withDevices: boolean = false): Promise { + return axiosAuth.get(`/users/me?withDevices=${withDevices}`).then(response => UserDto.copy(response.data)); } - public async me(withDevices: boolean = false, withAccessibleVaults: boolean = false): Promise { - return axiosAuth.get(`/users/me?withDevices=${withDevices}&withAccessibleVaults=${withAccessibleVaults}`).then(response => UserDto.copy(response.data)); + public async resetMe(): Promise { + return axiosAuth.post('/users/me/reset'); } public async listAll(): Promise { diff --git a/frontend/src/common/crypto.ts b/frontend/src/common/crypto.ts index 482e8cde7..9895b0b08 100644 --- a/frontend/src/common/crypto.ts +++ b/frontend/src/common/crypto.ts @@ -1,13 +1,7 @@ import * as miscreant from 'miscreant'; -import { base32, base64, base64url } from 'rfc4648'; -import { JWE } from './jwe'; -import { JWT, JWTHeader } from './jwt'; -import { CRC32, wordEncoder } from './util'; - -export class WrappedVaultKeys { - constructor(readonly masterkey: string, readonly signaturePrivateKey: string, readonly signaturePublicKey: string, readonly salt: string, readonly iterations: number) { } -} - +import { base16, base32, base64, base64url } from 'rfc4648'; +import { JWEBuilder, JWEParser } from './jwe'; +import { CRC32, DB, wordEncoder } from './util'; export class UnwrapKeyError extends Error { readonly actualError: any; @@ -28,21 +22,20 @@ export interface VaultConfigHeaderHub { clientId: string authEndpoint: string tokenEndpoint: string - devicesResourceUrl: string, authSuccessUrl: string authErrorUrl: string + apiBaseUrl: string + // deprecated: + devicesResourceUrl: string } interface JWEPayload { key: string } -export class VaultKeys { - private static readonly SIGNATURE_KEY_DESIGNATION: EcKeyImportParams | EcKeyGenParams = { - name: 'ECDSA', - namedCurve: 'P-384' - }; +const GCM_NONCE_LEN = 12; +export class VaultKeys { // in this browser application, this 512 bit key is used // as a hmac key to sign the vault config. // however when used by cryptomator, it gets split into @@ -53,19 +46,10 @@ export class VaultKeys { length: 512 }; - private static readonly KEK_KEY_DESIGNATION: AesDerivedKeyParams = { - name: 'AES-GCM', - length: 256 - }; - - private static readonly GCM_NONCE_LEN = 12; - private static readonly PBKDF2_ITERATION_COUNT = 1000000; readonly masterKey: CryptoKey; - readonly signatureKeyPair: CryptoKeyPair; - protected constructor(masterkey: CryptoKey, signatureKeyPair: CryptoKeyPair) { + protected constructor(masterkey: CryptoKey) { this.masterKey = masterkey; - this.signatureKeyPair = signatureKeyPair; } /** @@ -78,113 +62,86 @@ export class VaultKeys { true, ['sign'] ); - const keyPair = crypto.subtle.generateKey( - VaultKeys.SIGNATURE_KEY_DESIGNATION, - true, - ['sign', 'verify'] - ); - return new VaultKeys(await key, await keyPair); - } - - private static async pbkdf2(password: string, salt: Uint8Array, iterations: number): Promise { - const encodedPw = new TextEncoder().encode(password); - const pwKey = await crypto.subtle.importKey( - 'raw', - encodedPw, - 'PBKDF2', - false, - ['deriveKey'] - ); - return await crypto.subtle.deriveKey( - { - name: 'PBKDF2', - hash: 'SHA-256', - salt: salt, - iterations: iterations - }, - pwKey, - VaultKeys.KEK_KEY_DESIGNATION, - false, - ['wrapKey', 'unwrapKey'] - ); + return new VaultKeys(await key); } /** - * Protects the key material. Must only be called for a newly created masterkey, otherwise it will fail. - * @param password Password used for wrapping - * @returns The wrapped key material + * Decrypts the vault's masterkey using the user's private key + * @param jwe JWE containing the vault key + * @param userPrivateKey The user's private key + * @returns The masterkey */ - public async wrap(password: string): Promise { - // salt: - const salt = new Uint8Array(16); - crypto.getRandomValues(salt); - const encodedSalt = base64.stringify(salt); - // kek: - const kek = VaultKeys.pbkdf2(password, salt, VaultKeys.PBKDF2_ITERATION_COUNT); - // masterkey: - const masterKeyIv = crypto.getRandomValues(new Uint8Array(VaultKeys.GCM_NONCE_LEN)); - const wrappedMasterKey = new Uint8Array(await crypto.subtle.wrapKey( - 'raw', - this.masterKey, - await kek, - { name: 'AES-GCM', iv: masterKeyIv } - )); - const encodedMasterKey = base64.stringify(new Uint8Array([...masterKeyIv, ...wrappedMasterKey])); - // secretkey: - const secretKeyIv = crypto.getRandomValues(new Uint8Array(VaultKeys.GCM_NONCE_LEN)); - const wrappedSecretKey = new Uint8Array(await crypto.subtle.wrapKey( - 'pkcs8', - this.signatureKeyPair.privateKey, - await kek, - { name: 'AES-GCM', iv: secretKeyIv } - )); - const encodedSecretKey = base64.stringify(new Uint8Array([...secretKeyIv, ...wrappedSecretKey])); - // publickey: - const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', this.signatureKeyPair.publicKey)); - const encodedPublicKey = base64.stringify(publicKey); - // result: - return new WrappedVaultKeys(encodedMasterKey, encodedSecretKey, encodedPublicKey, encodedSalt, VaultKeys.PBKDF2_ITERATION_COUNT); + public static async decryptWithUserKey(jwe: string, userPrivateKey: CryptoKey): Promise { + let rawKey = new Uint8Array(); + try { + const payload: JWEPayload = await JWEParser.parse(jwe).decryptEcdhEs(userPrivateKey); + rawKey = base64.parse(payload.key); + const masterkey = crypto.subtle.importKey('raw', rawKey, VaultKeys.MASTERKEY_KEY_DESIGNATION, true, ['sign']); + return new VaultKeys(await masterkey); + } finally { + rawKey.fill(0x00); + } } /** - * Unwraps the key material. - * @param password Password used for wrapping - * @param wrapped The wrapped key material + * Unwraps keys protected by the legacy "Vault Admin Password". + * @param vaultAdminPassword Vault Admin Password + * @param wrappedMasterkey The wrapped masterkey + * @param wrappedOwnerPrivateKey The wrapped owner private key + * @param ownerPublicKey The owner public key + * @param salt PBKDF2 Salt + * @param iterations PBKDF2 Iterations * @returns The unwrapped key material. * @throws WrongPasswordError, if the wrong password is used + * @deprecated Only used during "claim vault ownership" workflow for legacy vaults */ - public static async unwrap(password: string, wrapped: WrappedVaultKeys): Promise { - const kek = VaultKeys.pbkdf2(password, base64.parse(wrapped.salt, { loose: true }), wrapped.iterations); - const decodedMasterKey = base64.parse(wrapped.masterkey, { loose: true }); - const decodedPrivateKey = base64.parse(wrapped.signaturePrivateKey, { loose: true }); - const decodedPublicKey = base64.parse(wrapped.signaturePublicKey, { loose: true }); + public static async decryptWithAdminPassword(vaultAdminPassword: string, wrappedMasterkey: string, wrappedOwnerPrivateKey: string, ownerPublicKey: string, salt: string, iterations: number): Promise<[VaultKeys, CryptoKeyPair]> { + // pbkdf2: + const encodedPw = new TextEncoder().encode(vaultAdminPassword); + const pwKey = crypto.subtle.importKey('raw', encodedPw, 'PBKDF2', false, ['deriveKey']); + const kek = crypto.subtle.deriveKey( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt: base64.parse(salt, { loose: true }), + iterations: iterations + }, + await pwKey, + { name: 'AES-GCM', length: 256 }, + false, + ['unwrapKey'] + ); + // unwrapping + const decodedMasterKey = base64.parse(wrappedMasterkey, { loose: true }); + const decodedPrivateKey = base64.parse(wrappedOwnerPrivateKey, { loose: true }); + const decodedPublicKey = base64.parse(ownerPublicKey, { loose: true }); try { const masterkey = crypto.subtle.unwrapKey( 'raw', - decodedMasterKey.slice(VaultKeys.GCM_NONCE_LEN), + decodedMasterKey.slice(GCM_NONCE_LEN), await kek, - { name: 'AES-GCM', iv: decodedMasterKey.slice(0, VaultKeys.GCM_NONCE_LEN) }, + { name: 'AES-GCM', iv: decodedMasterKey.slice(0, GCM_NONCE_LEN) }, VaultKeys.MASTERKEY_KEY_DESIGNATION, true, ['sign'] ); - const signPrivKey = crypto.subtle.unwrapKey( + const privKey = crypto.subtle.unwrapKey( 'pkcs8', - decodedPrivateKey.slice(VaultKeys.GCM_NONCE_LEN), + decodedPrivateKey.slice(GCM_NONCE_LEN), await kek, - { name: 'AES-GCM', iv: decodedPrivateKey.slice(0, VaultKeys.GCM_NONCE_LEN) }, - VaultKeys.SIGNATURE_KEY_DESIGNATION, + { name: 'AES-GCM', iv: decodedPrivateKey.slice(0, GCM_NONCE_LEN) }, + { name: 'ECDSA', namedCurve: 'P-384' }, false, ['sign'] ); - const signPubKey = crypto.subtle.importKey( + const pubKey = crypto.subtle.importKey( 'spki', decodedPublicKey, - VaultKeys.SIGNATURE_KEY_DESIGNATION, + { name: 'ECDSA', namedCurve: 'P-384' }, true, ['verify'] ); - return new VaultKeys(await masterkey, { privateKey: await signPrivKey, publicKey: await signPubKey }); + return [new VaultKeys(await masterkey), { privateKey: await privKey, publicKey: await pubKey }]; } catch (error) { throw new UnwrapKeyError(error); } @@ -217,12 +174,7 @@ export class VaultKeys { true, ['sign'] ); - const keyPair = crypto.subtle.generateKey( - VaultKeys.SIGNATURE_KEY_DESIGNATION, - true, - ['sign', 'verify'] - ); - return new VaultKeys(await key, await keyPair); + return new VaultKeys(await key); } public async createVaultConfig(kid: string, hubConfig: VaultConfigHeaderHub, payload: VaultConfigPayload): Promise { @@ -264,38 +216,22 @@ export class VaultKeys { /** * Encrypts this masterkey using the given public key - * @param devicePublicKey The recipient's public key (DER-encoded) + * @param userPublicKey The recipient's public key (DER-encoded) * @returns a JWE containing this Masterkey */ - public async encryptForDevice(devicePublicKey: Uint8Array): Promise { - const publicKey = await crypto.subtle.importKey( - 'spki', - devicePublicKey, - { - name: 'ECDH', - namedCurve: 'P-384' - }, - false, - [] - ); - + public async encryptForUser(userPublicKey: Uint8Array): Promise { + const publicKey = await crypto.subtle.importKey('spki', userPublicKey, UserKeys.KEY_DESIGNATION, false, []); const rawkey = new Uint8Array(await crypto.subtle.exportKey('raw', this.masterKey)); try { const payload: JWEPayload = { key: base64.stringify(rawkey) }; - const payloadJson = new TextEncoder().encode(JSON.stringify(payload)); - - return JWE.build(payloadJson, publicKey); + return JWEBuilder.ecdhEs(publicKey).encrypt(payload); } finally { rawkey.fill(0x00); } } - public async signVaultEditRequest(jwtHeader: JWTHeader, jwtPayload: any): Promise { - return JWT.build(jwtHeader, jwtPayload, this.signatureKeyPair.privateKey); - } - /** * Encode masterkey for offline backup purposes, allowing re-importing the key for recovery purposes */ @@ -313,3 +249,203 @@ export class VaultKeys { return wordEncoder.encodePadded(combined); } } + +export class UserKeys { + public static readonly KEY_USAGES: KeyUsage[] = ['deriveBits']; + + public static readonly KEY_DESIGNATION: EcKeyImportParams | EcKeyGenParams = { + name: 'ECDH', + namedCurve: 'P-384' + }; + + readonly keyPair: CryptoKeyPair; + + protected constructor(keyPair: CryptoKeyPair) { + this.keyPair = keyPair; + } + + /** + * Creates a new user key pair + * @returns A new user key pair + */ + public static async create(): Promise { + const keyPair = crypto.subtle.generateKey(UserKeys.KEY_DESIGNATION, true, UserKeys.KEY_USAGES); + return new UserKeys(await keyPair); + } + + /** + * Recovers the user key pair using a recovery code. All other information can be retrieved from the backend. + * @param encodedPublicKey The public key (base64-encoded SPKI) + * @param encryptedPrivateKey The JWE holding the encrypted private key + * @param setupCode The password used to protect the private key + * @returns Decrypted UserKeys + * @throws {UnwrapKeyError} when attempting to decrypt the private key using an incorrect setupCode + */ + public static async recover(encodedPublicKey: string, encryptedPrivateKey: string, setupCode: string): Promise { + const jwe: JWEPayload = await JWEParser.parse(encryptedPrivateKey).decryptPbes2(setupCode); + const decodedPublicKey = base64.parse(encodedPublicKey, { loose: true }); + const decodedPrivateKey = base64.parse(jwe.key, { loose: true }); + const privateKey = crypto.subtle.importKey('pkcs8', decodedPrivateKey, UserKeys.KEY_DESIGNATION, true, UserKeys.KEY_USAGES); + const publicKey = crypto.subtle.importKey('spki', decodedPublicKey, UserKeys.KEY_DESIGNATION, true, []); + return new UserKeys({ privateKey: await privateKey, publicKey: await publicKey }); + } + + /** + * Gets the base64-encoded public key in SPKI format. + * @returns base64-encoded public key + */ + public async encodedPublicKey(): Promise { + const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', this.keyPair.publicKey)); + return base64.stringify(publicKey); + } + + /** + * Encrypts the user's private key using a key derived from the given setupCode + * @param setupCode The password to protect the private key. + * @returns A JWE holding the encrypted private key + * @see JWEBuilder.pbes2 + */ + public async encryptedPrivateKey(setupCode: string): Promise { + const rawkey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.keyPair.privateKey)); + try { + const payload: JWEPayload = { + key: base64.stringify(rawkey) + }; + return await JWEBuilder.pbes2(setupCode).encrypt(payload); + } finally { + rawkey.fill(0x00); + } + } + + /** + * Encrypts the user's private key using the given public key + * @param devicePublicKey The device's public key (DER-encoded) + * @returns a JWE containing the PKCS#8-encoded private key + * @see JWEBuilder.ecdhEs + */ + public async encryptForDevice(devicePublicKey: CryptoKey | Uint8Array): Promise { + const publicKey = await UserKeys.publicKey(devicePublicKey); + const rawkey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.keyPair.privateKey)); + try { + const payload: JWEPayload = { + key: base64.stringify(rawkey) + }; + return JWEBuilder.ecdhEs(publicKey).encrypt(payload); + } finally { + rawkey.fill(0x00); + } + } + + /** + * Decrypts the user's private key using the browser's private key + * @param jwe JWE containing the PKCS#8-encoded private key + * @param browserPrivateKey The browser's private key + * @param userPublicKey User public key + * @returns The user's key pair + */ + public static async decryptOnBrowser(jwe: string, browserPrivateKey: CryptoKey, userPublicKey: CryptoKey | BufferSource): Promise { + const publicKey = await UserKeys.publicKey(userPublicKey); + let rawKey = new Uint8Array(); + try { + const payload: JWEPayload = await JWEParser.parse(jwe).decryptEcdhEs(browserPrivateKey); + rawKey = base64.parse(payload.key); + const privateKey = await crypto.subtle.importKey('pkcs8', rawKey, UserKeys.KEY_DESIGNATION, true, UserKeys.KEY_USAGES); + return new UserKeys({ publicKey: publicKey, privateKey: privateKey }); + } finally { + rawKey.fill(0x00); + } + } + + private static async publicKey(publicKey: CryptoKey | BufferSource): Promise { + if (publicKey instanceof CryptoKey) { + return publicKey; + } else { + return await crypto.subtle.importKey('spki', publicKey, UserKeys.KEY_DESIGNATION, true, []); + } + } +} + +export class BrowserKeys { + public static readonly KEY_USAGES: KeyUsage[] = ['deriveBits']; + + private static readonly KEY_DESIGNATION: EcKeyImportParams | EcKeyGenParams = { + name: 'ECDH', + namedCurve: 'P-384' + }; + + readonly keyPair: CryptoKeyPair; + + protected constructor(keyPair: CryptoKeyPair) { + this.keyPair = keyPair; + } + + /** + * Creates a new device key pair for this browser + * @returns A new device key pair + */ + public static async create(): Promise { + const keyPair = crypto.subtle.generateKey(BrowserKeys.KEY_DESIGNATION, false, BrowserKeys.KEY_USAGES); + return new BrowserKeys(await keyPair); + } + + /** + * Attempts to load previously stored key pair from the browser's IndexedDB. + * @returns a promise resolving to the loaded browser key pair + */ + public static async load(userId: string): Promise { + const keyPair: CryptoKeyPair = await DB.transaction('keys', 'readonly', tx => { + const keyStore = tx.objectStore('keys'); + return keyStore.get(userId); + }); + if (keyPair) { + return new BrowserKeys(keyPair); + } else { + return undefined; + } + } + + /** + * Deletes the key pair for the given user. + * @returns a promise resolving on success + */ + public static async delete(userId: string): Promise { + await DB.transaction('keys', 'readwrite', tx => { + const keyStore = tx.objectStore('keys'); + return keyStore.delete(userId); + }); + } + + /** + * Stores the key pair in the browser's IndexedDB. See https://www.w3.org/TR/WebCryptoAPI/#concepts-key-storage + * @returns a promise that will resolve if the key pair has been saved + */ + public async store(userId: string): Promise { + await DB.transaction('keys', 'readwrite', tx => { + const keyStore = tx.objectStore('keys'); + return keyStore.put(this.keyPair, userId); + }); + } + + public async id(): Promise { + const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', this.keyPair.publicKey)); + const hash = new Uint8Array(await crypto.subtle.digest({ name: 'SHA-256' }, publicKey)); + return base16.stringify(hash).toUpperCase(); + } + + public async encodedPublicKey() { + const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', this.keyPair.publicKey)); + return base64.stringify(publicKey); + } +} + +export async function getFingerprint(key: string | undefined) { + if (key) { + const encodedKey = new TextEncoder().encode(key); + const hashBuffer = await crypto.subtle.digest("SHA-256", encodedKey); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, "0").toUpperCase()) + .join(""); + return hashHex; + } +} diff --git a/frontend/src/common/jwe.ts b/frontend/src/common/jwe.ts index 66c363647..6ea42ade3 100644 --- a/frontend/src/common/jwe.ts +++ b/frontend/src/common/jwe.ts @@ -1,25 +1,17 @@ import { base64url } from 'rfc4648'; +import { UnwrapKeyError } from './crypto'; +// visible for testing export class ConcatKDF { /** * KDF as defined in NIST SP 800-56A Rev. 2 Section 5.8.1 using SHA-256 * * @param z A shared secret * @param keyDataLen Desired key length (in bytes) - * @param algorithmId Purpose of the derived key material - * @param partyUInfo Public information about party U - * @param partyVInfo Public information about party V - * @param suppPubInfo Mutually known public information (optional) - * @param suppPrivInfo Mutually known private information (optional) + * @param otherInfo Optional context info binding the derived key to a key agreement (see e.g. RFC 7518, Section 4.6.2) * @returns key data */ - public static async kdf(z: Uint8Array, keyDataLen: number, algorithmId: Uint8Array, partyUInfo: Uint8Array, partyVInfo: Uint8Array, suppPubInfo: Uint8Array = new Uint8Array(), suppPrivInfo: Uint8Array = new Uint8Array()): Promise { - // AlgorithmID || PartyUInfo || PartyVInfo {|| SuppPubInfo }{|| SuppPrivInfo } - const otherInfo = new Uint8Array([...algorithmId, ...partyUInfo, ...partyVInfo, ...suppPubInfo, ...suppPrivInfo]); - return this.kdfInternal(z, keyDataLen, new Uint8Array(otherInfo)); - } - - private static async kdfInternal(z: Uint8Array, keyDataLen: number, otherInfo: Uint8Array): Promise { + public static async kdf(z: Uint8Array, keyDataLen: number, otherInfo: Uint8Array): Promise { const hashLen = 32; // output length of SHA-256 const reps = Math.ceil(keyDataLen / hashLen); if (reps >= 0xFFFFFFFF) { @@ -43,90 +35,259 @@ export class ConcatKDF { } } -export class JWEHeader { - constructor(readonly alg: string, readonly enc: string, readonly epk: JsonWebKey | null, readonly apu?: string, readonly apv?: string) { } +export type JWEHeader = { + readonly alg: 'ECDH-ES' | 'PBES2-HS512+A256KW', + readonly enc: 'A256GCM' | 'A128GCM', + readonly apu?: string, + readonly apv?: string, + readonly epk?: JsonWebKey, + readonly p2c?: number, + readonly p2s?: string } -export class JWE { +export const ECDH_P384: EcKeyImportParams | EcKeyGenParams = { + name: 'ECDH', + namedCurve: 'P-384' +}; + +export class JWEParser { + readonly header: JWEHeader; + readonly encryptedKey: Uint8Array; + readonly iv: Uint8Array; + readonly ciphertext: Uint8Array; + readonly tag: Uint8Array; + + private constructor(readonly encodedHeader: string, readonly encodedEncryptedKey: string, readonly encodedIv: string, readonly encodedCiphertext: string, readonly encodedTag: string) { + const utf8dec = new TextDecoder(); + this.header = JSON.parse(utf8dec.decode(base64url.parse(encodedHeader, { loose: true }))); + this.encryptedKey = base64url.parse(encodedEncryptedKey, { loose: true }); + this.iv = base64url.parse(encodedIv, { loose: true }); + this.ciphertext = base64url.parse(encodedCiphertext, { loose: true }); + this.tag = base64url.parse(encodedTag, { loose: true }); + } + /** - * Creates a JWE using ECDH-ES using the P-384 curve and AES-256-GCM for payload encryption. - * - * See RFC 7516 + RFC 7518, Section 4.6 - * - * @param payload The secret payload - * @param devicePublicKey The recipient's public key - * @param apu Optional public information about the producer (PartyUInfo) - * @param apv Optional public information about the recipient (PartyVInfo) + * Decodes the JWE. + * @param jwe The JWE string + * @returns Decoded JWE, ready to decrypt. */ - public static async build(payload: Uint8Array, recipientPublicKey: CryptoKey, apu: Uint8Array = new Uint8Array(), apv: Uint8Array = new Uint8Array()): Promise { - /* key agreement and header params described in RFC 7518, Section 4.6: */ - const ephemeralKey = await crypto.subtle.generateKey( + public static parse(jwe: string): JWEParser { + const [encodedHeader, encodedEncryptedKey, encodedIv, encodedCiphertext, encodedTag] = jwe.split('.', 5); + return new JWEParser(encodedHeader, encodedEncryptedKey, encodedIv, encodedCiphertext, encodedTag); + } + + /** + * Decrypts the JWE, assuming alg == ECDH-ES, enc == A256GCM and keys on the P-384 curve. + * @param recipientPrivateKey The recipient's private key + * @returns Decrypted payload + */ + public async decryptEcdhEs(recipientPrivateKey: CryptoKey): Promise { + if (this.header.alg != 'ECDH-ES' || this.header.enc != 'A256GCM' || !this.header.epk) { + throw new Error('unsupported alg or enc'); + } + const ephemeralKey = await crypto.subtle.importKey('jwk', this.header.epk, ECDH_P384, false, []); + const cek = await ECDH_ES.deriveContentKey(ephemeralKey, recipientPrivateKey, 384, 32, this.header); + return this.decrypt(cek); + } + + /** + * Decrypts the JWE, assuming alg == PBES2-HS512+A256KW and enc == A256GCM. + * @param password The password to feed into the KDF + * @returns Decrypted payload + * @throws {UnwrapKeyError} if decryption failed (wrong password?) + */ + public async decryptPbes2(password: string): Promise { + if (this.header.alg != 'PBES2-HS512+A256KW' || /* this.header.enc != 'A256GCM' || */ !this.header.p2s || !this.header.p2c) { + throw new Error('unsupported alg or enc'); + } + const saltInput = base64url.parse(this.header.p2s, { loose: true }); + const wrappingKey = await PBES2.deriveWrappingKey(password, this.header.alg, saltInput, this.header.p2c); + try { + const cek = crypto.subtle.unwrapKey('raw', this.encryptedKey, wrappingKey, 'AES-KW', { name: 'AES-GCM', length: 256 }, false, ['decrypt']); + return this.decrypt(await cek); + } catch (error) { + throw new UnwrapKeyError(error); + } + } + + private async decrypt(cek: CryptoKey): Promise { + const utf8enc = new TextEncoder(); + const m = new Uint8Array(this.ciphertext.length + this.tag.length); + m.set(this.ciphertext, 0); + m.set(this.tag, this.ciphertext.length); + const payloadJson = new Uint8Array(await crypto.subtle.decrypt( { - name: 'ECDH', - namedCurve: 'P-384' + name: 'AES-GCM', + iv: this.iv, + additionalData: utf8enc.encode(this.encodedHeader), + tagLength: 128 }, - false, - ['deriveBits'] - ); - const alg = 'ECDH-ES'; - const enc = 'A256GCM'; - const epk = await crypto.subtle.exportKey('jwk', ephemeralKey.publicKey); - const header = new JWEHeader(alg, enc, epk, base64url.stringify(apu, { pad: false }), base64url.stringify(apv, { pad: false })); + cek, + m + )); + return JSON.parse(new TextDecoder().decode(payloadJson)); + } +} + +export class JWEBuilder { + private constructor(readonly header: Promise, readonly encryptedKey: Promise, readonly cek: Promise) { } + + /** + * Prepares a new JWE using alg: ECDH-ES and enc: A256GCM. + * + * @param recipientPublicKey Static public key of the JWE's recipient + * @param apu Optional information about the creator + * @param apv Optional information about the recipient + * @returns A new JWEBuilder ready to encrypt the payload + */ + public static ecdhEs(recipientPublicKey: CryptoKey, apu: Uint8Array = new Uint8Array(), apv: Uint8Array = new Uint8Array()): JWEBuilder { + /* key agreement and header params described in RFC 7518, Section 4.6: */ + const ephemeralKey = crypto.subtle.generateKey(ECDH_P384, false, ['deriveBits']); + const header = (async () => { + alg: 'ECDH-ES', + enc: 'A256GCM', + epk: await crypto.subtle.exportKey('jwk', (await ephemeralKey).publicKey), + apu: base64url.stringify(apu, { pad: false }), + apv: base64url.stringify(apv, { pad: false }) + })(); + const encryptedKey = (async () => Uint8Array.of())(); // empty for Direct Key Agreement as per spec + const cek = (async () => ECDH_ES.deriveContentKey(recipientPublicKey, (await ephemeralKey).privateKey, 384, 32, await header))(); + return new JWEBuilder(header, encryptedKey, cek); + } + + /** + * Prepares a new JWE using alg: PBES2-HS512+A256KW and enc: A256GCM. + * + * @param password The password to feed into the KDF + * @param iterations The PBKDF2 iteration count (defaults to {@link PBES2.DEFAULT_ITERATION_COUNT} ) + * @param apu Optional information about the creator + * @param apv Optional information about the recipient + * @returns A new JWEBuilder ready to encrypt the payload + */ + public static pbes2(password: string, iterations: number = PBES2.DEFAULT_ITERATION_COUNT, apu: Uint8Array = new Uint8Array(), apv: Uint8Array = new Uint8Array()): JWEBuilder { + const saltInput = crypto.getRandomValues(new Uint8Array(16)); + const header = (async () => { + alg: 'PBES2-HS512+A256KW', + enc: 'A256GCM', + p2s: base64url.stringify(saltInput, { pad: false }), + p2c: iterations, + apu: base64url.stringify(apu, { pad: false }), + apv: base64url.stringify(apv, { pad: false }) + })(); + const wrappingKey = PBES2.deriveWrappingKey(password, 'PBES2-HS512+A256KW', saltInput, iterations); + const cek = crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); + const encryptedKey = (async () => new Uint8Array(await crypto.subtle.wrapKey('raw', await cek, await wrappingKey, 'AES-KW')))(); + return new JWEBuilder(header, encryptedKey, cek); + } + + /** + * Builds the JWE. + * @param payload Payload to be encrypted + * @returns The JWE + */ + public async encrypt(payload: object) { + const utf8enc = new TextEncoder(); /* JWE assembly and content encryption described in RFC 7516: */ - const encodedHeader = base64url.stringify(new TextEncoder().encode(JSON.stringify(header)), { pad: false }); + const encodedHeader = base64url.stringify(utf8enc.encode(JSON.stringify(await this.header)), { pad: false }); const iv = crypto.getRandomValues(new Uint8Array(12)); const encodedIv = base64url.stringify(iv, { pad: false }); - const encodedEncryptedKey = ''; // empty for Direct Key Agreement as per spec - const cek = await this.deriveKey(recipientPublicKey, ephemeralKey.privateKey, 384, 32, header); + const encodedEncryptedKey = base64url.stringify(await this.encryptedKey, { pad: false }); const m = new Uint8Array(await crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv, - additionalData: new TextEncoder().encode(encodedHeader), + additionalData: utf8enc.encode(encodedHeader), tagLength: 128 }, - cek, - payload + await this.cek, + utf8enc.encode(JSON.stringify(payload)) )); console.assert(m.byteLength > 16, 'result of GCM encryption expected to contain 128bit tag'); const ciphertext = m.slice(0, m.byteLength - 16); const tag = m.slice(m.byteLength - 16); const encodedCiphertext = base64url.stringify(ciphertext, { pad: false }); const encodedTag = base64url.stringify(tag, { pad: false }); - return encodedHeader + '.' + encodedEncryptedKey + '.' + encodedIv + '.' + encodedCiphertext + '.' + encodedTag; + return `${encodedHeader}.${encodedEncryptedKey}.${encodedIv}.${encodedCiphertext}.${encodedTag}`; } +} - // visible for testing - public static async deriveKey(recipientPublicKey: CryptoKey, ephemeralSecretKey: CryptoKey, ecdhKeyBits: number, desiredKeyBytes: number, header: JWEHeader, exportable: boolean = false): Promise { +// visible for testing +export class ECDH_ES { + public static async deriveContentKey(publicKey: CryptoKey, privateKey: CryptoKey, ecdhKeyBits: number, desiredKeyBytes: number, header: JWEHeader, exportable: boolean = false): Promise { let agreedKey = new Uint8Array(); let derivedKey = new Uint8Array(); try { - const algorithmId = this.lengthPrefixed(new TextEncoder().encode(header.enc)); - const partyUInfo = this.lengthPrefixed(base64url.parse(header.apu || '', { loose: true })); - const partyVInfo = this.lengthPrefixed(base64url.parse(header.apv || '', { loose: true })); + const algorithmId = ECDH_ES.lengthPrefixed(new TextEncoder().encode(header.enc)); + const partyUInfo = ECDH_ES.lengthPrefixed(base64url.parse(header.apu || '', { loose: true })); + const partyVInfo = ECDH_ES.lengthPrefixed(base64url.parse(header.apv || '', { loose: true })); const suppPubInfo = new ArrayBuffer(4); new DataView(suppPubInfo).setUint32(0, desiredKeyBytes * 8, false); agreedKey = new Uint8Array(await crypto.subtle.deriveBits( { name: 'ECDH', - public: recipientPublicKey + public: publicKey }, - ephemeralSecretKey, + privateKey, ecdhKeyBits )); - derivedKey = await ConcatKDF.kdf(new Uint8Array(agreedKey), desiredKeyBytes, algorithmId, partyUInfo, partyVInfo, new Uint8Array(suppPubInfo)); - return crypto.subtle.importKey('raw', derivedKey, { name: 'AES-GCM', length: desiredKeyBytes * 8 }, exportable, ['encrypt']); + const otherInfo = new Uint8Array([...algorithmId, ...partyUInfo, ...partyVInfo, ...new Uint8Array(suppPubInfo)]); + derivedKey = await ConcatKDF.kdf(new Uint8Array(agreedKey), desiredKeyBytes, otherInfo); + return crypto.subtle.importKey('raw', derivedKey, { name: 'AES-GCM', length: desiredKeyBytes * 8 }, exportable, ['encrypt', 'decrypt']); } finally { derivedKey.fill(0x00); agreedKey.fill(0x00); } } - private static lengthPrefixed(data: Uint8Array): Uint8Array { - const result = new ArrayBuffer(4 + data.byteLength); - new DataView(result, 0, 4).setUint32(0, data.byteLength, false); - new Uint8Array(result).set(data, 4); - return new Uint8Array(result); + public static lengthPrefixed(data: Uint8Array): Uint8Array { + const result = new Uint8Array(4 + data.byteLength); + new DataView(result.buffer, 0, 4).setUint32(0, data.byteLength, false); + result.set(data, 4); + return result; + } +} + +// visible for testing +export class PBES2 { + public static readonly DEFAULT_ITERATION_COUNT = 1000000; + private static readonly NULL_BYTE = Uint8Array.of(0x00); + + // TODO: can we dedup this with crypto.ts's PBKDF2? Or is the latter unused anyway, once we migrate all ciphertext to JWE containers + public static async deriveWrappingKey(password: string, alg: 'PBES2-HS512+A256KW' | 'PBES2-HS256+A128KW', salt: Uint8Array, iterations: number, extractable: boolean = false): Promise { + let hash, keyLen; + if (alg == 'PBES2-HS512+A256KW') { + hash = 'SHA-512'; + keyLen = 256; + } else if (alg == 'PBES2-HS256+A128KW') { + hash = 'SHA-256'; + keyLen = 128; + } else { + throw new Error('only PBES2-HS512+A256KW and PBES2-HS256+A128KW supported'); + } + const utf8enc = new TextEncoder(); + const encodedPw = utf8enc.encode(password); + const pwKey = crypto.subtle.importKey( + 'raw', + encodedPw, + 'PBKDF2', + false, + ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + hash: hash, + salt: new Uint8Array([...utf8enc.encode(alg), ...PBES2.NULL_BYTE, ...salt]), // see https://www.rfc-editor.org/rfc/rfc7518#section-4.8.1.1 + iterations: iterations + }, + await pwKey, + { + name: 'AES-KW', + length: keyLen + }, + extractable, + ['wrapKey', 'unwrapKey'] + ); } } diff --git a/frontend/src/common/jwt.ts b/frontend/src/common/jwt.ts index 2a30851bc..16979d945 100644 --- a/frontend/src/common/jwt.ts +++ b/frontend/src/common/jwt.ts @@ -1,7 +1,6 @@ import { base64url } from 'rfc4648'; -export interface JWTHeader { - +export type JWTHeader = { alg: 'ES384'; typ: 'JWT'; b64: true; diff --git a/frontend/src/common/util.ts b/frontend/src/common/util.ts index 13692402a..b127c6889 100644 --- a/frontend/src/common/util.ts +++ b/frontend/src/common/util.ts @@ -1,5 +1,26 @@ import { dictionary } from './4096words_en'; +export class DB { + private static readonly NAME = 'hub'; + + public static async transaction(objectStore: string, mode: IDBTransactionMode, query: (transaction: IDBTransaction) => IDBRequest): Promise { + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open(DB.NAME); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + req.onupgradeneeded = () => req.result.createObjectStore(objectStore); + }); + const transaction = db.transaction(objectStore, mode); + return new Promise((resolve, reject) => { + const req = query(transaction); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }).finally(() => { + db.close(); + }); + } +} + export class Deferred { public promise: Promise; public reject: (reason?: any) => void; diff --git a/frontend/src/common/vaultconfig.ts b/frontend/src/common/vaultconfig.ts index 312a57159..ba6b380f5 100644 --- a/frontend/src/common/vaultconfig.ts +++ b/frontend/src/common/vaultconfig.ts @@ -20,9 +20,10 @@ export class VaultConfig { clientId: cfg.keycloakClientIdCryptomator, authEndpoint: cfg.keycloakAuthEndpoint, tokenEndpoint: cfg.keycloakTokenEndpoint, - devicesResourceUrl: `${absBackendBaseURL}devices/`, authSuccessUrl: `${absFrontendBaseURL}unlock-success?vault=${vaultId}`, - authErrorUrl: `${absFrontendBaseURL}unlock-error?vault=${vaultId}` + authErrorUrl: `${absFrontendBaseURL}unlock-error?vault=${vaultId}`, + apiBaseUrl: absBackendBaseURL, + devicesResourceUrl: `${absBackendBaseURL}devices/`, }; const jwtPayload: VaultConfigPayload = { diff --git a/frontend/src/components/AdminSettings.vue b/frontend/src/components/AdminSettings.vue index 86a1a2f75..d9554d4a4 100644 --- a/frontend/src/components/AdminSettings.vue +++ b/frontend/src/components/AdminSettings.vue @@ -193,9 +193,10 @@ import { useI18n } from 'vue-i18n'; import backend, { BillingDto, VersionDto } from '../common/backend'; import config, { absFrontendBaseURL } from '../common/config'; import { FetchUpdateError, LatestVersionDto, updateChecker } from '../common/updatecheck'; +import { Locale } from '../i18n/index'; import FetchError from './FetchError.vue'; -const { t, d, locale } = useI18n({ useScope: 'global' }); +const { t, d, locale, fallbackLocale } = useI18n({ useScope: 'global' }); const props = defineProps<{ token?: string @@ -265,7 +266,9 @@ async function fetchData() { function manageSubscription() { const returnUrl = `${absFrontendBaseURL}admin`; - const languagePathComponent = locale.value == 'en' ? '' : `${locale.value}/`; + const supportedLanguages = [Locale.EN, Locale.DE]; + const supportedLanguagePathComponents = Object.fromEntries(supportedLanguages.map(lang => [lang, lang == Locale.EN ? '' : `${lang}/`])); + const languagePathComponent = supportedLanguagePathComponents[(locale.value as string).split('-')[0]] ?? supportedLanguagePathComponents[fallbackLocale.value as string] ?? ''; window.open(`https://cryptomator.org/${languagePathComponent}hub/billing/?hub_id=${admin.value?.hubId}&return_url=${encodeURIComponent(returnUrl)}`, '_self'); } diff --git a/frontend/src/components/ArchiveVaultDialog.vue b/frontend/src/components/ArchiveVaultDialog.vue index 7d657d17f..1e1a846e0 100644 --- a/frontend/src/components/ArchiveVaultDialog.vue +++ b/frontend/src/components/ArchiveVaultDialog.vue @@ -81,7 +81,7 @@ async function archiveVault() { onArchiveVaultError.value = null; const v = props.vault; try { - const vaultDto = await backend.vaults.createOrUpdateVault(v.id, v.name, v.description, true, v.masterkey, v.iterations, v.salt, v.authPublicKey, v.authPrivateKey ); + const vaultDto = await backend.vaults.createOrUpdateVault(v.id, v.name, true, v.description); emit('archived', vaultDto); open.value = false; } catch (error) { diff --git a/frontend/src/components/AuditLog.vue b/frontend/src/components/AuditLog.vue index 1b6855172..ccf5558d0 100644 --- a/frontend/src/components/AuditLog.vue +++ b/frontend/src/components/AuditLog.vue @@ -109,6 +109,8 @@ + + @@ -162,7 +164,7 @@ import { ChevronDownIcon } from '@heroicons/vue/20/solid'; import { CheckIcon, ChevronUpDownIcon, WrenchIcon } from '@heroicons/vue/24/solid'; import { computed, onMounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; -import auditlog, { AuditEventDeviceRegisterDto, AuditEventDeviceRemoveDto, AuditEventDto, AuditEventVaultAccessGrantDto, AuditEventVaultCreateDto, AuditEventVaultKeyRetrieveDto, AuditEventVaultMemberAddDto, AuditEventVaultMemberRemoveDto, AuditEventVaultUpdateDto } from '../common/auditlog'; +import auditlog, { AuditEventDeviceRegisterDto, AuditEventDeviceRemoveDto, AuditEventDto, AuditEventVaultAccessGrantDto, AuditEventVaultCreateDto, AuditEventVaultKeyRetrieveDto, AuditEventVaultMemberAddDto, AuditEventVaultMemberRemoveDto, AuditEventVaultMemberUpdateDto, AuditEventVaultOwnershipClaimDto, AuditEventVaultUpdateDto } from '../common/auditlog'; import { PaymentRequiredError } from '../common/backend'; import AuditLogDetailsDeviceRegister from './AuditLogDetailsDeviceRegister.vue'; import AuditLogDetailsDeviceRemove from './AuditLogDetailsDeviceRemove.vue'; @@ -171,6 +173,8 @@ import AuditLogDetailsVaultCreate from './AuditLogDetailsVaultCreate.vue'; import AuditLogDetailsVaultKeyRetrieve from './AuditLogDetailsVaultKeyRetrieve.vue'; import AuditLogDetailsVaultMemberAdd from './AuditLogDetailsVaultMemberAdd.vue'; import AuditLogDetailsVaultMemberRemove from './AuditLogDetailsVaultMemberRemove.vue'; +import AuditLogDetailsVaultMemberUpdate from './AuditLogDetailsVaultMemberUpdate.vue'; +import AuditLogDetailsVaultOwnershipClaim from './AuditLogDetailsVaultOwnershipClaim.vue'; import AuditLogDetailsVaultUpdate from './AuditLogDetailsVaultUpdate.vue'; import FetchError from './FetchError.vue'; diff --git a/frontend/src/components/AuditLogDetailsVaultMemberAdd.vue b/frontend/src/components/AuditLogDetailsVaultMemberAdd.vue index 9a42c94e7..51322d610 100644 --- a/frontend/src/components/AuditLogDetailsVaultMemberAdd.vue +++ b/frontend/src/components/AuditLogDetailsVaultMemberAdd.vue @@ -31,6 +31,16 @@ {{ event.authorityId }} +

+
+ role +
+
+ Member + Owner + {{ event.role }} +
+
diff --git a/frontend/src/components/AuditLogDetailsVaultMemberUpdate.vue b/frontend/src/components/AuditLogDetailsVaultMemberUpdate.vue new file mode 100644 index 000000000..017f96386 --- /dev/null +++ b/frontend/src/components/AuditLogDetailsVaultMemberUpdate.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend/src/components/AuditLogDetailsVaultOwnershipClaim.vue b/frontend/src/components/AuditLogDetailsVaultOwnershipClaim.vue new file mode 100644 index 000000000..035a73d0b --- /dev/null +++ b/frontend/src/components/AuditLogDetailsVaultOwnershipClaim.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/src/components/AuthenticatedMain.vue b/frontend/src/components/AuthenticatedMain.vue index 18d0063f9..bb9e6610c 100644 --- a/frontend/src/components/AuthenticatedMain.vue +++ b/frontend/src/components/AuthenticatedMain.vue @@ -35,7 +35,7 @@ onMounted(fetchData); async function fetchData() { onFetchError.value = null; try { - me.value = await backend.users.me(true, true); + me.value = await backend.users.me(); } catch (error) { console.error('Retrieving logged in user failed.', error); onFetchError.value = error instanceof Error ? error : new Error('Unknown Error'); diff --git a/frontend/src/components/AuthenticateVaultAdminDialog.vue b/frontend/src/components/ClaimVaultOwnershipDialog.vue similarity index 82% rename from frontend/src/components/AuthenticateVaultAdminDialog.vue rename to frontend/src/components/ClaimVaultOwnershipDialog.vue index 8c6588ecc..da081453b 100644 --- a/frontend/src/components/AuthenticateVaultAdminDialog.vue +++ b/frontend/src/components/ClaimVaultOwnershipDialog.vue @@ -17,30 +17,30 @@
- {{ t('authenticateVaultAdminDialog.title') }} + {{ t('claimVaultOwnershipDialog.title') }}

- {{ t('authenticateVaultAdminDialog.description') }} + {{ t('claimVaultOwnershipDialog.description') }}

- +

- {{ t('authenticateVaultAdminDialog.error.formValidationFailed') }} + {{ t('claimVaultOwnershipDialog.error.formValidationFailed') }}

- {{ t('authenticateVaultAdminDialog.error.wrongPassword') }} + {{ t('claimVaultOwnershipDialog.error.wrongPassword') }}

{{ t('common.unexpectedError', [onAuthenticationError.message]) }} @@ -62,7 +62,7 @@ import { KeyIcon } from '@heroicons/vue/24/outline'; import { ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { VaultDto } from '../common/backend'; -import { UnwrapKeyError, VaultKeys, WrappedVaultKeys } from '../common/crypto'; +import { UnwrapKeyError, VaultKeys } from '../common/crypto'; class FormValidationFailedError extends Error { constructor() { @@ -85,7 +85,7 @@ const props = defineProps<{ const emit = defineEmits<{ close: [] - action: [vaultKeys: VaultKeys] + action: [vaultKeys: VaultKeys, ownerSigningKey: CryptoKeyPair] }>(); defineExpose({ @@ -102,10 +102,13 @@ async function authenticateVaultAdmin() { if (!form.value?.checkValidity()) { throw new FormValidationFailedError(); } - const wrappedKey = new WrappedVaultKeys(props.vault.masterkey, props.vault.authPrivateKey, props.vault.authPublicKey, props.vault.salt, props.vault.iterations); - const vaultKeys = await VaultKeys.unwrap(password.value, wrappedKey); - emit('action', vaultKeys); - open.value = false; + if (props.vault.masterkey && props.vault.authPrivateKey && props.vault.authPublicKey && props.vault.salt && props.vault.iterations) { + const [vaultKeys, ownerKeyPair] = await VaultKeys.decryptWithAdminPassword(password.value, props.vault.masterkey, props.vault.authPrivateKey, props.vault.authPublicKey, props.vault.salt, props.vault.iterations); + emit('action', vaultKeys, ownerKeyPair); + open.value = false; + } else { + throw new Error('Vault is missing legacy "vault admin password" related properties.'); + } } catch (error) { console.error('Authentication of vault admin failed.', error); onAuthenticationError.value = error instanceof Error ? error : new Error('Unknown Error'); diff --git a/frontend/src/components/CreateVault.vue b/frontend/src/components/CreateVault.vue index c480e5ed6..02cca908b 100644 --- a/frontend/src/components/CreateVault.vue +++ b/frontend/src/components/CreateVault.vue @@ -6,7 +6,7 @@

-
+
@@ -64,22 +64,6 @@ ({{ t('common.optional') }})
- -
- - -

{{ t('createVault.enterVaultDetails.password.description') }}

-
- -
- - -

- {{ t('createVault.enterVaultDetails.passwordConfirmation.passwordsMatch') }} - {{ t('createVault.enterVaultDetails.passwordConfirmation.passwordsDoNotMatch') }} -   -

-
@@ -88,7 +72,6 @@

{{ t('createVault.error.formValidationFailed') }}

-

{{ t('createVault.enterVaultDetails.passwordConfirmation.passwordsDoNotMatch') }}

{{ t('common.unexpectedError', [onCreateError.message]) }}