From b0ed068380b0f3034d30100ebed2e96a6669ffff Mon Sep 17 00:00:00 2001 From: barreiro Date: Mon, 26 Aug 2024 14:38:07 +0100 Subject: [PATCH] API keys feature --- .../api/internal/services/UserService.java | 55 +++++ .../tools/horreum/entity/user/UserApiKey.java | 164 +++++++++++++ .../tools/horreum/entity/user/UserInfo.java | 5 + .../horreum/notification/EmailPlugin.java | 22 ++ .../horreum/notification/Notification.java | 5 + .../server/ApiKeyAuthenticationMechanism.java | 63 +++++ .../server/ApiKeyIdentityProvider.java | 49 ++++ .../tools/horreum/server/RolesAugmentor.java | 60 +++-- .../horreum/server/SecurityBootstrap.java | 1 + .../horreum/svc/NotificationServiceImpl.java | 15 ++ .../tools/horreum/svc/TimeService.java | 5 + .../tools/horreum/svc/UserServiceImpl.java | 133 ++++++++-- .../horreum/svc/user/DatabaseUserBackend.java | 12 + .../horreum/svc/user/KeycloakUserBackend.java | 26 +- .../tools/horreum/svc/user/UserBackEnd.java | 2 + .../src/main/resources/db/changeLog.xml | 32 +++ .../templates/api_key_expiration_email.html | 12 + .../horreum/svc/UserServiceAbstractTest.java | 74 ++++++ .../io/hyperfoil/tools/HorreumClient.java | 11 +- .../auth/HorreumApiKeyAuthentication.java | 30 +++ .../tools/horreum/it/HorreumClientIT.java | 33 +++ horreum-web/src/domain/user/ApiKeys.tsx | 228 ++++++++++++++++++ horreum-web/src/domain/user/UserSettings.tsx | 15 +- 23 files changed, 992 insertions(+), 60 deletions(-) create mode 100644 horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserApiKey.java create mode 100644 horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/ApiKeyAuthenticationMechanism.java create mode 100644 horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/ApiKeyIdentityProvider.java create mode 100644 horreum-backend/src/main/resources/templates/api_key_expiration_email.html create mode 100644 horreum-client/src/main/java/io/hyperfoil/tools/auth/HorreumApiKeyAuthentication.java create mode 100644 horreum-web/src/domain/user/ApiKeys.tsx diff --git a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/internal/services/UserService.java b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/internal/services/UserService.java index 857a059a4..580a0895f 100644 --- a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/internal/services/UserService.java +++ b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/internal/services/UserService.java @@ -1,5 +1,6 @@ package io.hyperfoil.tools.horreum.api.internal.services; +import java.time.LocalDate; import java.util.List; import java.util.Map; @@ -8,6 +9,7 @@ import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; @@ -118,6 +120,28 @@ List searchUsers( @Blocking String resetPassword(@PathParam("team") String team, @RequestBody(required = true) String username); + @POST + @Path("/apikey") + @Produces("text/plain") + @Blocking + String newApiKey(@RequestBody ApiKeyRequest request); + + @GET + @Path("/apikey") + @Blocking + List apiKeys(); + + @PUT + @Path("/apikey/{id}/rename") + @Consumes("text/plain") + @Blocking + void renameApiKey(@PathParam("id") long keyId, @RequestBody String newName); + + @PUT + @Path("/apikey/{id}/revoke") + @Blocking + void revokeApiKey(@PathParam("id") long keyId); + // this is a simplified copy of org.keycloak.representations.idm.UserRepresentation class UserData { @NotNull @@ -146,4 +170,35 @@ class NewUser { public String team; public List roles; } + + /** + * Key type allows to scope what the key gives access to + */ + enum KeyType { + USER + } + + class ApiKeyRequest { + public String name; + public KeyType type; + + public ApiKeyRequest() { + } + + public ApiKeyRequest(String name, KeyType type) { + this.name = name; + this.type = type; + } + } + + class ApiKeyResponse { + public long id; + public String name; + public KeyType type; + public LocalDate creation; + public LocalDate access; + public boolean isRevoked; + public long toExpiration; + } + } diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserApiKey.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserApiKey.java new file mode 100644 index 000000000..609f4e4ed --- /dev/null +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserApiKey.java @@ -0,0 +1,164 @@ +package io.hyperfoil.tools.horreum.entity.user; + +import static jakarta.persistence.GenerationType.SEQUENCE; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Comparator; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; + +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.NamedQueries; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; + +import io.hyperfoil.tools.horreum.api.internal.services.UserService; +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; + +@Entity +@Table(name = "userinfo_apikey") +@NamedQueries({ + // fetch all keys that expire on a given day + @NamedQuery(name = "UserApiKey.expire", query = "from UserApiKey where not revoked AND (access is null and (creation + active day) = ?1 or (access + active day) = ?1)"), + // fetch all keys that have gone past their expiration date + @NamedQuery(name = "UserApiKey.pastExpiration", query = "from UserApiKey where not revoked AND (access is null and (creation + active day) < ?1 or (access + active day) < ?1)"), +}) +public class UserApiKey extends PanacheEntityBase implements Comparable { + + // old authentication tokens are not listed and can't be modified, but are kept around to prevent re-use + public static long ARCHIVE_AFTER_DAYS = 7; + + @Id + @SequenceGenerator(name = "apikeyIdGenerator", sequenceName = "userinfo_apikey_id_seq", allocationSize = 1) + @GeneratedValue(strategy = SEQUENCE, generator = "apikeyIdGenerator") + public long id; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "username") + public UserInfo user; + + @Transient + private final UUID randomnessSource; + + private final String hash; + + public String name; + + @Enumerated + public final UserService.KeyType type; + + public LocalDate creation, access; + + public long active; // number of days after last access that the key remains active + + public boolean revoked; + + public UserApiKey() { + randomnessSource = null; + hash = null; + name = null; + type = UserService.KeyType.USER; + } + + public UserApiKey(UserService.ApiKeyRequest request, LocalDate creation, long valid) { + this(request.name, request.type, creation, valid); + } + + public UserApiKey(String name, UserService.KeyType type, LocalDate creationDate, long valid) { + randomnessSource = UUID.randomUUID(); + this.name = name == null ? "" : name; + this.type = type; + this.active = valid; + hash = computeHash(keyString()); + creation = creationDate; + revoked = false; + } + + public UserService.ApiKeyResponse toResponse() { + UserService.ApiKeyResponse response = new UserService.ApiKeyResponse(); + response.id = id; + response.name = name; + response.type = type; + response.creation = creation; + response.access = access; + response.isRevoked = revoked; + response.toExpiration = toExpiration(LocalDate.now()); + return response; + } + + public boolean isArchived(LocalDate givenDay) { + return givenDay.isAfter((access == null ? creation : access).plusDays(active + ARCHIVE_AFTER_DAYS)); + } + + // calculate the number of days left until expiration (if negative it's the number of days after expiration) + private long toExpiration(LocalDate givenDay) { + return active - ChronoUnit.DAYS.between(access == null ? creation : access, givenDay); + } + + public String keyString() { + StringBuilder builder = new StringBuilder(50); + builder.append("H"); + switch (type) { + case USER: + builder.append("USR"); + break; + default: + builder.append("UNK"); + } + builder.append("_"); + builder.append(randomnessSource.toString().replace("-", "_").toUpperCase()); // keep the dashes for quick validation of key format + return builder.toString(); + } + + // returns the SHA-256 hash of a given key. the hash is what gets sored in the DB, and it's what get compared for authentication + private static String computeHash(String key) { + try { + return Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(key.getBytes())); + } catch (NoSuchAlgorithmException e) { + return null; // ignore: SHA-256 should exist + } + } + + public static Optional findOptional(String key) { + // validate key structure before computing hash + if (key.startsWith("H") && Stream.of(4, 13, 18, 23, 28).allMatch(i -> key.charAt(i) == '_')) { + return UserApiKey. find("hash", computeHash(key)).firstResultOptional(); + } else { + return Optional.empty(); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o == null || getClass() != o.getClass()) { + return false; + } + return Objects.equals(this.id, ((UserApiKey) o).id) && Objects.equals(this.hash, ((UserApiKey) o).hash); + } + + @Override + public int hashCode() { + return Objects.hash(id, hash); + } + + @Override + public int compareTo(UserApiKey other) { + return Comparator. comparing(a -> a.creation).thenComparing(a -> a.id).compare(this, other); + } +} diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserInfo.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserInfo.java index 2ab4b15c7..416ed7486 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserInfo.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserInfo.java @@ -71,6 +71,10 @@ public class UserInfo extends PanacheEntityBase { @Cache(usage = CacheConcurrencyStrategy.READ_ONLY) public Set teams; + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user") + @Cache(usage = CacheConcurrencyStrategy.READ_ONLY) + public Set apiKeys; + public UserInfo() { } @@ -78,6 +82,7 @@ public UserInfo(String username) { this.username = username; this.roles = new HashSet<>(); this.teams = new HashSet<>(); + this.apiKeys = new HashSet<>(); } public void setPassword(String clearPassword) { diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/notification/EmailPlugin.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/notification/EmailPlugin.java index cacdbc66e..4827a4138 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/notification/EmailPlugin.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/notification/EmailPlugin.java @@ -5,6 +5,7 @@ import java.text.SimpleDateFormat; import java.time.Duration; import java.time.Instant; +import java.time.LocalDate; import java.util.Date; import jakarta.enterprise.context.ApplicationScoped; @@ -44,6 +45,9 @@ public class EmailPlugin implements NotificationPlugin { @Location("expected_run_notification_email") Template expectedRunNotificationEmail; + @Location("api_key_expiration_email") + Template apiKeyExpirationEmail; + @Inject ReactiveMailer mailer; @@ -147,6 +151,24 @@ public void notifyExpectedRun(String testName, int testId, long before, String e mailer.send(Mail.withHtml(data, subject, content)).await().atMost(sendMailTimeout); log.debug("Sending mail: " + content); } + + @Override + public void notifyApiKeyExpiration(String keyName, LocalDate creation, LocalDate lastAccess, long toExpiration, + long active) { + String subject = String.format("%s API key \"%s\" %s", subjectPrefix, keyName, + toExpiration == -1 ? "EXPIRED" : "about to expire"); + String content = apiKeyExpirationEmail + .data("baseUrl", baseUrl) + .data("username", username) + .data("keyName", keyName) + .data("creation", creation) + .data("lastAccess", lastAccess) + .data("expiration", toExpiration) + .data("active", active) + .render(); + mailer.send(Mail.withHtml(data, subject, content)).await().atMost(sendMailTimeout); + log.debug("Sending mail: " + content); + } } private String prettyPrintTime(long duration) { diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/notification/Notification.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/notification/Notification.java index 02e92abf2..5a1d03aec 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/notification/Notification.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/notification/Notification.java @@ -1,6 +1,7 @@ package io.hyperfoil.tools.horreum.notification; import java.time.Instant; +import java.time.LocalDate; import io.hyperfoil.tools.horreum.events.DatasetChanges; import io.hyperfoil.tools.horreum.svc.MissingValuesEvent; @@ -22,4 +23,8 @@ public abstract void notifyMissingDataset(String testName, int testId, String ru public abstract void notifyMissingValues(String testName, String fingerprint, MissingValuesEvent missing); public abstract void notifyExpectedRun(String testName, int testId, long before, String expectedBy, String backlink); + + public abstract void notifyApiKeyExpiration(String keyName, LocalDate creation, LocalDate lastAccess, long toExpiration, + long active); + } diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/ApiKeyAuthenticationMechanism.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/ApiKeyAuthenticationMechanism.java new file mode 100644 index 000000000..d5628ba06 --- /dev/null +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/ApiKeyAuthenticationMechanism.java @@ -0,0 +1,63 @@ +package io.hyperfoil.tools.horreum.server; + +import static io.quarkus.vertx.http.runtime.security.HttpCredentialTransport.Type.OTHER_HEADER; + +import java.util.Collections; +import java.util.Set; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.netty.handler.codec.http.HttpResponseStatus; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.security.identity.request.BaseAuthenticationRequest; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.HttpCredentialTransport; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +/** + * Look for a special HTTP header to provide authentication of HTTP requests + */ +@ApplicationScoped +public class ApiKeyAuthenticationMechanism implements HttpAuthenticationMechanism { + + public static final String HORREUM_API_KEY_HEADER = "X-Horreum-API-Key"; + + @Override + public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + String headerValue = context.request().headers().get(HORREUM_API_KEY_HEADER); + return headerValue == null ? Uni.createFrom().nullItem() + : identityProviderManager.authenticate(new Request(headerValue)); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return Uni.createFrom().item(new ChallengeData(HttpResponseStatus.UNAUTHORIZED.code(), null, null)); + } + + @Override + public Set> getCredentialTypes() { + return Collections.singleton(Request.class); + } + + @Override + public Uni getCredentialTransport(RoutingContext context) { + return Uni.createFrom().item(new HttpCredentialTransport(OTHER_HEADER, HORREUM_API_KEY_HEADER)); + } + + public static class Request extends BaseAuthenticationRequest implements AuthenticationRequest { + + private final String key; + + public Request(String key) { + this.key = key; + } + + public String getKey() { + return key; + } + } +} diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/ApiKeyIdentityProvider.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/ApiKeyIdentityProvider.java new file mode 100644 index 000000000..b98c003fc --- /dev/null +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/ApiKeyIdentityProvider.java @@ -0,0 +1,49 @@ +package io.hyperfoil.tools.horreum.server; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + +import io.hyperfoil.tools.horreum.entity.user.UserApiKey; +import io.hyperfoil.tools.horreum.svc.TimeService; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.mutiny.Uni; + +/** + * Retrieve and validate the key got from {@link ApiKeyAuthenticationMechanism} and create a SecurityIdentity from it. + */ +@ApplicationScoped +public class ApiKeyIdentityProvider implements IdentityProvider { + + @Inject + TimeService timeService; + + @Override + public Class getRequestType() { + return ApiKeyAuthenticationMechanism.Request.class; + } + + @Override + public Uni authenticate(ApiKeyAuthenticationMechanism.Request request, + AuthenticationRequestContext context) { + return context.runBlocking(() -> identityFromKey(request.getKey())); + } + + @Transactional + SecurityIdentity identityFromKey(String key) { + return UserApiKey.findOptional(key) + .filter(k -> !k.revoked) + .map((userKey) -> { + // update last access + userKey.access = timeService.today(); + + // create identity with just the principal, roles will be populated in RolesAugmentor + return QuarkusSecurityIdentity.builder().setPrincipal(new QuarkusPrincipal(userKey.user.username)).build(); + }) + .orElse(null); + } +} diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/RolesAugmentor.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/RolesAugmentor.java index 8f5138e26..d00298252 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/RolesAugmentor.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/RolesAugmentor.java @@ -1,15 +1,13 @@ package io.hyperfoil.tools.horreum.server; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; -import io.hyperfoil.tools.horreum.entity.user.TeamMembership; -import io.hyperfoil.tools.horreum.entity.user.UserInfo; -import io.hyperfoil.tools.horreum.entity.user.UserRole; import io.hyperfoil.tools.horreum.svc.ServiceException; -import io.quarkus.arc.lookup.LookupIfProperty; +import io.hyperfoil.tools.horreum.svc.user.UserBackEnd; import io.quarkus.arc.profile.UnlessBuildProfile; import io.quarkus.security.identity.AuthenticationRequestContext; import io.quarkus.security.identity.SecurityIdentity; @@ -19,7 +17,6 @@ @ApplicationScoped @UnlessBuildProfile("test") -@LookupIfProperty(name = "horreum.roles.provider", stringValue = "database") public class RolesAugmentor implements SecurityIdentityAugmentor { @Inject @@ -28,46 +25,61 @@ public class RolesAugmentor implements SecurityIdentityAugmentor { @ConfigProperty(name = "horreum.roles.database.override", defaultValue = "true") boolean override; + @ConfigProperty(name = "horreum.roles.provider") + String provider; + + @Inject + Instance backend; + @Override public Uni augment(SecurityIdentity identity, AuthenticationRequestContext context) { return identity.isAnonymous() ? Uni.createFrom().item(identity) : context.runBlocking(() -> addHorreumRoles(identity)); } private SecurityIdentity addHorreumRoles(SecurityIdentity identity) { + return switch (provider) { + case "database" -> rolesFromDB(identity); + case "keycloak" -> rolesFromKeycloak(identity); + default -> identity; + }; + } + + private SecurityIdentity rolesFromDB(SecurityIdentity identity) { String username = identity.getPrincipal().getName(); String previousRoles = roleManager.setRoles(username); try { - UserInfo user = UserInfo.findById(username); + QuarkusSecurityIdentity.Builder builder; if (override) { - if (user == null) { - throw ServiceException.serverError("Unable to fetch user entity"); - } - QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(); + builder = QuarkusSecurityIdentity.builder(); builder.setAnonymous(false); builder.setPrincipal(identity.getPrincipal()); builder.addAttributes(identity.getAttributes()); builder.addCredentials(identity.getCredentials()); builder.addPermissionChecker(identity::checkPermission); - - addRoles(builder, user); - return builder.build(); } else { - if (user == null) { - return identity; - } - QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity); - addRoles(builder, user); - return builder.build(); + builder = QuarkusSecurityIdentity.builder(identity); + } + backend.get().getRoles(username).forEach(builder::addRole); + return builder.build(); + } catch (Exception e) { + if (override) { + throw ServiceException.serverError("Unable to fetch user entity"); + } else { + return identity; // ignore exception when the user does not exist } } finally { roleManager.setRoles(previousRoles); } } - private void addRoles(QuarkusSecurityIdentity.Builder builder, UserInfo user) { - user.roles.stream().map(UserRole::toString).map(String::toLowerCase).forEach(builder::addRole); - user.teams.stream().map(TeamMembership::asRole).forEach(builder::addRole); - user.teams.stream().map(TeamMembership::asTeam).forEach(builder::addRole); - user.teams.stream().map(TeamMembership::asUIRole).distinct().forEach(builder::addRole); + private SecurityIdentity rolesFromKeycloak(SecurityIdentity identity) { + // no roles mean authentication from a horreum auth token. only in that case fetch roles from keycloak + if (identity.getRoles().isEmpty()) { + QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity); + backend.get().getRoles(identity.getPrincipal().getName()).forEach(builder::addRole); + return builder.build(); + } else { + return identity; + } } } diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/SecurityBootstrap.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/SecurityBootstrap.java index eecc31858..efa2db770 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/SecurityBootstrap.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/server/SecurityBootstrap.java @@ -152,6 +152,7 @@ public void checkBootstrapAccount() { // create db entry, if not existent, like in UserService.createLocalUser() UserInfo userInfo = UserInfo. findByIdOptional(BOOTSTRAP_ACCOUNT).orElse(new UserInfo(BOOTSTRAP_ACCOUNT)); userInfo.defaultTeam = "dev-team"; + userInfo.persist(); Log.infov("\n>>>\n>>> Created temporary account {0} with password {1}\n>>>", BOOTSTRAP_ACCOUNT, user.password); } else if (administrators.size() > 1 && administrators.contains(BOOTSTRAP_ACCOUNT)) { diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/NotificationServiceImpl.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/NotificationServiceImpl.java index ebbdae43e..a091a5e1a 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/NotificationServiceImpl.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/NotificationServiceImpl.java @@ -28,6 +28,7 @@ import io.hyperfoil.tools.horreum.entity.alerting.NotificationSettingsDAO; import io.hyperfoil.tools.horreum.entity.data.DatasetDAO; import io.hyperfoil.tools.horreum.entity.data.TestDAO; +import io.hyperfoil.tools.horreum.entity.user.UserApiKey; import io.hyperfoil.tools.horreum.events.DatasetChanges; import io.hyperfoil.tools.horreum.mapper.NotificationSettingsMapper; import io.hyperfoil.tools.horreum.notification.Notification; @@ -190,4 +191,18 @@ public void notifyExpectedRun(int testId, long expectedBefore, String expectedBy String name = test != null ? test.name : ""; notifyAll(testId, n -> n.notifyExpectedRun(name, testId, expectedBefore, expectedBy, backlink)); } + + @WithRoles(extras = Roles.HORREUM_SYSTEM) + public void notifyApiKeyExpiration(UserApiKey key, long toExpiration) { + NotificationSettingsDAO. stream("name", key.user.username).forEach(notification -> { + NotificationPlugin plugin = plugins.get(notification.method); + if (plugin == null) { + log.errorf("Cannot notify %s of API key \"%s\" expiration: no plugin for method %s", + notification.name, key.name, notification.method); + } else { + plugin.create(notification.name, notification.data) + .notifyApiKeyExpiration(key.name, key.creation, key.access, toExpiration, key.active); + } + }); + } } diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/TimeService.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/TimeService.java index 215a5228c..2cb3c0038 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/TimeService.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/TimeService.java @@ -1,6 +1,7 @@ package io.hyperfoil.tools.horreum.svc; import java.time.Instant; +import java.time.LocalDate; import jakarta.enterprise.context.ApplicationScoped; @@ -12,4 +13,8 @@ public class TimeService { public Instant now() { return Instant.now(); } + + public LocalDate today() { + return LocalDate.now(); + } } diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/UserServiceImpl.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/UserServiceImpl.java index 29aff07bb..5d852af18 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/UserServiceImpl.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/UserServiceImpl.java @@ -9,6 +9,7 @@ import java.util.Map; import java.util.function.Function; +import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.RolesAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Instance; @@ -16,10 +17,12 @@ import jakarta.transaction.Transactional; import io.hyperfoil.tools.horreum.api.internal.services.UserService; +import io.hyperfoil.tools.horreum.entity.user.UserApiKey; import io.hyperfoil.tools.horreum.entity.user.UserInfo; import io.hyperfoil.tools.horreum.server.WithRoles; import io.hyperfoil.tools.horreum.svc.user.UserBackEnd; import io.quarkus.logging.Log; +import io.quarkus.scheduler.Scheduled; import io.quarkus.security.Authenticated; import io.quarkus.security.identity.SecurityIdentity; @@ -27,6 +30,11 @@ @ApplicationScoped public class UserServiceImpl implements UserService { + /** + * Default number of days API keys remain active after it's used to access the system + */ + public static final long DEFAULT_API_KEY_ACTIVE_DAYS = 30; + private static final int RANDOM_PASSWORD_LENGTH = 15; @Inject @@ -35,6 +43,21 @@ public class UserServiceImpl implements UserService { @Inject Instance backend; + @Inject + NotificationServiceImpl notificationServiceimpl; + + @Inject + TimeService timeService; + + private UserInfo currentUser() { + return UserInfo. findByIdOptional(getUsername()) + .orElseThrow(() -> ServiceException.notFound(format("Username {0} not found", getUsername()))); + } + + private String getUsername() { + return identity.getPrincipal().getName(); + } + @Override public List getRoles() { return identity.getRoles().stream().toList(); @@ -52,7 +75,7 @@ public List info(List usernames) { return backend.get().info(usernames); } - // ideally we want to enforce these roles in some of the endpoints, but for now this has to be done in the code + // ideally we want to enforce these roles in some endpoints, but for now this has to be done in the code // @RolesAllowed({ Roles.ADMIN, Roles.MANAGER }) @Override public void createUser(NewUser user) { @@ -61,19 +84,19 @@ public void createUser(NewUser user) { backend.get().createUser(user); createLocalUser(user.user.username, user.team, user.roles != null && user.roles.contains(Roles.MACHINE) ? user.password : null); - Log.infov("{0} created user {1} {2} with username {3} on team {4}", identity.getPrincipal().getName(), + Log.infov("{0} created user {1} {2} with username {3} on team {4}", getUsername(), user.user.firstName, user.user.lastName, user.user.username, user.team); } @RolesAllowed({ Roles.ADMIN, Roles.MANAGER }) @Override public void removeUser(String username) { - if (identity.getPrincipal().getName().equals(username)) { + if (getUsername().equals(username)) { throw ServiceException.badRequest("Cannot remove yourself"); } backend.get().removeUser(username); removeLocalUser(username); - Log.infov("{0} removed user {1}", identity.getPrincipal().getName(), username); + Log.infov("{0} removed user {1}", getUsername(), username); } @Override @@ -85,9 +108,9 @@ public List getTeams() { @WithRoles(extras = Roles.HORREUM_SYSTEM) @Override public String defaultTeam() { - UserInfo userInfo = UserInfo.findById(identity.getPrincipal().getName()); + UserInfo userInfo = currentUser(); if (userInfo == null) { - throw ServiceException.notFound(format("User with username {0} not found", identity.getPrincipal().getName())); + throw ServiceException.notFound(format("User with username {0} not found", getUsername())); } return userInfo.defaultTeam != null ? userInfo.defaultTeam : ""; } @@ -96,9 +119,9 @@ public String defaultTeam() { @WithRoles(addUsername = true) @Override public void setDefaultTeam(String unsafeTeam) { - UserInfo userInfo = UserInfo.findById(identity.getPrincipal().getName()); + UserInfo userInfo = currentUser(); if (userInfo == null) { - throw ServiceException.notFound(format("User with username {0} not found", identity.getPrincipal().getName())); + throw ServiceException.notFound(format("User with username {0} not found", getUsername())); } userInfo.defaultTeam = validateTeamName(unsafeTeam); userInfo.persistAndFlush(); @@ -140,7 +163,7 @@ public List getAllTeams() { public void addTeam(String unsafeTeam) { String team = validateTeamName(unsafeTeam); backend.get().addTeam(team); - Log.infov("{0} created team {1}", identity.getPrincipal().getName(), team); + Log.infov("{0} created team {1}", getUsername(), team); } @RolesAllowed(Roles.ADMIN) @@ -148,7 +171,7 @@ public void addTeam(String unsafeTeam) { public void deleteTeam(String unsafeTeam) { String team = validateTeamName(unsafeTeam); backend.get().deleteTeam(team); - Log.infov("{0} deleted team {1}", identity.getPrincipal().getName(), team); + Log.infov("{0} deleted team {1}", getUsername(), team); } @RolesAllowed(Roles.ADMIN) @@ -160,7 +183,7 @@ public List administrators() { @RolesAllowed(Roles.ADMIN) @Override public void updateAdministrators(List newAdmins) { - if (!newAdmins.contains(identity.getPrincipal().getName())) { + if (!newAdmins.contains(getUsername())) { throw ServiceException.badRequest("Cannot remove yourself from administrator list"); } backend.get().updateAdministrators(newAdmins); @@ -187,14 +210,12 @@ public String resetPassword(String unsafeTeam, String username) { if (backend.get().machineAccounts(team).stream().noneMatch(data -> data.username.equals(username))) { throw ServiceException.badRequest(format("User {0} is not machine account of team {1}", username, team)); } - UserInfo userInfo = UserInfo.findById(username); - if (userInfo == null) { - throw ServiceException.notFound(format("User with username {0} not found", username)); - } String newPassword = new SecureRandom().ints(RANDOM_PASSWORD_LENGTH, '0', 'z' + 1) .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString(); - userInfo.setPassword(newPassword); - Log.infov("{0} reset password of user {1}", identity.getPrincipal().getName(), username); + UserInfo. findByIdOptional(username) + .orElseThrow(() -> ServiceException.notFound(format("Username {0} not found", username))) + .setPassword(newPassword); + Log.infov("{0} reset password of user {1}", getUsername(), username); return newPassword; } @@ -271,6 +292,84 @@ void removeLocalUser(String username) { // --- // + @Transactional + @WithRoles(addUsername = true) + @Override + public String newApiKey(ApiKeyRequest request) { + validateApiKeyName(request.name == null ? "" : request.name); + UserInfo userInfo = currentUser(); + + UserApiKey newKey = new UserApiKey(request, timeService.today(), DEFAULT_API_KEY_ACTIVE_DAYS); + newKey.user = userInfo; + userInfo.apiKeys.add(newKey); + newKey.persist(); + userInfo.persist(); + + Log.infov("{0} created API key \"{1}\"", getUsername(), request.name == null ? "" : request.name); + return newKey.keyString(); + } + + @Transactional + @WithRoles(extras = Roles.HORREUM_SYSTEM) + @Override + public List apiKeys() { + return currentUser().apiKeys.stream() + .filter(t -> !t.isArchived(timeService.today())) + .sorted() + .map(UserApiKey::toResponse) + .toList(); + } + + @Transactional + @WithRoles(addUsername = true) + @Override + public void renameApiKey(long keyId, String newName) { + validateApiKeyName(newName == null ? "" : newName); + UserApiKey key = UserApiKey. findByIdOptional(keyId) + .orElseThrow(() -> ServiceException.notFound(format("Key with id {0} not found", keyId))); + if (key.revoked) { + throw ServiceException.badRequest("Can't rename revoked key"); + } + String oldName = key.name; + key.name = newName == null ? "" : newName; + Log.infov("{0} renamed API key \"{1}\" to \"{2}\"", getUsername(), oldName, newName == null ? "" : newName); + } + + @Transactional + @WithRoles(addUsername = true) + @Override + public void revokeApiKey(long keyId) { + UserApiKey key = UserApiKey. findByIdOptional(keyId) + .orElseThrow(() -> ServiceException.notFound(format("Key with id {0} not found", keyId))); + key.revoked = true; + Log.infov("{0} revoked API key \"{1}\"", getUsername(), key.name); + } + + @PermitAll + @Transactional + @WithRoles(extras = Roles.HORREUM_SYSTEM) + @Scheduled(every = "P1d") // daily -- it may lag up tp 24h compared to the actual date, but keys are revoked 24h after notification + public void apiKeyDailyTask() { + // notifications of keys expired and about to expire -- hardcoded to send multiple notices in the week prior to expiration + for (long toExpiration : List.of(7, 2, 1, 0, -1)) { + UserApiKey. stream("#UserApiKey.expire", timeService.today().plusDays(toExpiration)) + .forEach(key -> notificationServiceimpl.notifyApiKeyExpiration(key, toExpiration)); + } + // revoke expired keys -- could be done directly in the DB but iterate instead to be able to log + UserApiKey. stream("#UserApiKey.pastExpiration", timeService.today()).forEach(key -> { + Log.infov("Revoked idle API key \"{0}\"", key.name); + key.revoked = true; + }); + } + + private void validateApiKeyName(String keyName) { + if (keyName.startsWith("horreum.")) { + throw ServiceException.badRequest("key names starting with 'horreum.' are reserved for internal use"); + } + } + + // --- // + public static final class FirstParameter implements Function { @Override public String[] apply(Object[] objects) { diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/DatabaseUserBackend.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/DatabaseUserBackend.java index d4fe57adc..e56bbe9c7 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/DatabaseUserBackend.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/DatabaseUserBackend.java @@ -49,6 +49,18 @@ private static String removeTeamSuffix(String team) { return team.substring(0, team.length() - 5); } + @Transactional + @Override + public List getRoles(String username) { + UserInfo user = UserInfo.findById(username); + List roles = new ArrayList<>(); + user.roles.stream().map(UserRole::toString).map(String::toLowerCase).forEach(roles::add); + user.teams.stream().map(TeamMembership::asRole).forEach(roles::add); + user.teams.stream().map(TeamMembership::asTeam).forEach(roles::add); + user.teams.stream().map(TeamMembership::asUIRole).distinct().forEach(roles::add); + return roles; + } + @Transactional @WithRoles(extras = Roles.HORREUM_SYSTEM) @Override diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/KeycloakUserBackend.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/KeycloakUserBackend.java index 7529f5805..cab984948 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/KeycloakUserBackend.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/KeycloakUserBackend.java @@ -86,6 +86,12 @@ private Set safeMachineIds() { } } + @Override + public List getRoles(String username) { + return keycloak.realm(realm).users().get(findMatchingUser(username).getId()).roles().realmLevel().listAll().stream() + .map(RoleRepresentation::getName).toList(); + } + @Override public List info(List usernames) { List users = new ArrayList<>(); @@ -125,13 +131,7 @@ public void createUser(UserService.NewUser user) { try { // assign the provided roles to the realm UsersResource usersResource = keycloak.realm(realm).users(); - List matchingUsers = usersResource.search(rep.getUsername(), true); - if (matchingUsers == null || matchingUsers.isEmpty()) { - throw ServiceException.badRequest(format("User {0} does not exist", rep.getUsername())); - } else if (matchingUsers.size() > 1) { - throw ServiceException.serverError(format("More than one user with username {0}", rep.getUsername())); - } - String userId = matchingUsers.get(0).getId(); + String userId = findMatchingUser(rep.getUsername()).getId(); if (user.team != null) { String prefix = getTeamPrefix(user.team); @@ -168,7 +168,7 @@ public void createUser(UserService.NewUser user) { @Override public void removeUser(String username) { - try (Response response = keycloak.realm(realm).users().delete(findMatchingUserId(username))) { + try (Response response = keycloak.realm(realm).users().delete(findMatchingUser(username).getId())) { if (response.getStatusInfo().getFamily() != Response.Status.Family.SUCCESSFUL) { LOG.warnv("Got {0} response for removing user {0}", response.getStatusInfo(), username); throw ServiceException.serverError(format("Unable to remove user {0}", username)); @@ -207,7 +207,7 @@ public List getTeams() { // get the "team roles" in the realm } } - private String findMatchingUserId(String username) { // find the clientID of a single user + private UserRepresentation findMatchingUser(String username) { // find the clientID of a single user List matchingUsers = keycloak.realm(realm).users().search(username, true); if (matchingUsers == null || matchingUsers.isEmpty()) { LOG.warnv("Cannot find user with username {0}", username); @@ -217,7 +217,7 @@ private String findMatchingUserId(String username) { // find the clientID of a s matchingUsers.stream().map(UserRepresentation::getId).collect(joining(" "))); throw ServiceException.serverError(format("More than one user with username {0}", username)); } - return matchingUsers.get(0).getId(); + return matchingUsers.get(0); } @Override @@ -247,7 +247,7 @@ public void updateTeamMembers(String team, Map> roles) { // RoleMappingResource rolesMappingResource; try { // fetch the current roles for the user - String userId = findMatchingUserId(entry.getKey()); + String userId = findMatchingUser(entry.getKey()).getId(); rolesMappingResource = keycloak.realm(realm).users().get(userId).roles(); existingRoles = rolesMappingResource.getAll().getRealmMappings().stream().map(RoleRepresentation::getName) .toList(); @@ -396,7 +396,7 @@ public void updateAdministrators(List newAdmins) { // update the list of for (String username : newAdmins) { // add admin role for `newAdmins` not in `oldAdmins` if (oldAdmins.stream().noneMatch(old -> username.equals(old.getUsername()))) { try { - usersResource.get(findMatchingUserId(username)).roles().realmLevel().add(List.of(adminRole)); + usersResource.get(findMatchingUser(username).getId()).roles().realmLevel().add(List.of(adminRole)); LOG.infov("Added administrator role to user {0}", username); } catch (Throwable t) { LOG.warnv("Could not add admin role to user {0} due to {1}", username, t.getMessage()); @@ -431,7 +431,7 @@ public void setPassword(String username, String password) { credentials.setType(CredentialRepresentation.PASSWORD); credentials.setValue(password); - keycloak.realm(realm).users().get(findMatchingUserId(username)).resetPassword(credentials); + keycloak.realm(realm).users().get(findMatchingUser(username).getId()).resetPassword(credentials); } catch (Throwable t) { LOG.warnv(t, "Failed to retrieve current representation of user {0} from Keycloak", username); throw ServiceException diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/UserBackEnd.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/UserBackEnd.java index f04937238..bf55ce30e 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/UserBackEnd.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/UserBackEnd.java @@ -10,6 +10,8 @@ */ public interface UserBackEnd { + List getRoles(String username); + List searchUsers(String query); List info(List usernames); diff --git a/horreum-backend/src/main/resources/db/changeLog.xml b/horreum-backend/src/main/resources/db/changeLog.xml index c269d5493..1c428818d 100644 --- a/horreum-backend/src/main/resources/db/changeLog.xml +++ b/horreum-backend/src/main/resources/db/changeLog.xml @@ -4566,4 +4566,36 @@ CREATE INDEX idx_label_metrics_partial ON label (id) WHERE metrics = TRUE; + + ANY + + + + + + + + + + + + + + + + + + + + + + GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE userinfo_apikey TO "${quarkus.datasource.username}"; + GRANT ALL ON SEQUENCE userinfo_apikey_id_seq TO "${quarkus.datasource.username}"; + ALTER TABLE userinfo_apikey ENABLE ROW LEVEL SECURITY; + CREATE POLICY userinfo_apikey_read ON userinfo_apikey USING (has_role('horreum.system')); + CREATE POLICY userinfo_apikey_rw ON userinfo_apikey FOR ALL USING (has_role(username)); + + diff --git a/horreum-backend/src/main/resources/templates/api_key_expiration_email.html b/horreum-backend/src/main/resources/templates/api_key_expiration_email.html new file mode 100644 index 000000000..218c535d7 --- /dev/null +++ b/horreum-backend/src/main/resources/templates/api_key_expiration_email.html @@ -0,0 +1,12 @@ +

Hello {username},

+

The API key "{keyName}" created on {creation} + {#when expiration} + {#is < 0} HAS EXPIRED. + {#is < 1} expires today. + {#is < 2} expires tomorrow. + {#else} expires in {expiration} days. + {/when} +

+

The key was {#if lastAccess == null}never used{#else}last used on {lastAccess}{/if}.

+

Remember that this key is automatically revoked if not used for {active} days.

+Horreum Alerting diff --git a/horreum-backend/src/test/java/io/hyperfoil/tools/horreum/svc/UserServiceAbstractTest.java b/horreum-backend/src/test/java/io/hyperfoil/tools/horreum/svc/UserServiceAbstractTest.java index 667722c21..52d3c28fc 100644 --- a/horreum-backend/src/test/java/io/hyperfoil/tools/horreum/svc/UserServiceAbstractTest.java +++ b/horreum-backend/src/test/java/io/hyperfoil/tools/horreum/svc/UserServiceAbstractTest.java @@ -1,5 +1,7 @@ package io.hyperfoil.tools.horreum.svc; +import static io.hyperfoil.tools.horreum.api.internal.services.UserService.KeyType.USER; +import static io.hyperfoil.tools.horreum.svc.UserServiceImpl.DEFAULT_API_KEY_ACTIVE_DAYS; import static io.restassured.RestAssured.given; import static org.apache.http.HttpStatus.SC_OK; import static org.apache.http.HttpStatus.SC_UNAUTHORIZED; @@ -9,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -25,6 +28,7 @@ import org.junit.jupiter.api.Test; import io.hyperfoil.tools.horreum.api.internal.services.UserService; +import io.hyperfoil.tools.horreum.entity.user.UserApiKey; import io.hyperfoil.tools.horreum.entity.user.UserInfo; import io.hyperfoil.tools.horreum.server.SecurityBootstrap; import io.hyperfoil.tools.horreum.server.WithRoles; @@ -56,6 +60,9 @@ public abstract class UserServiceAbstractTest { @Inject SecurityBootstrap securitiyBootstrap; + @Inject + TimeService timeService; + /** * Runs a section of a test under a different user */ @@ -554,4 +561,71 @@ void bootstrapAccount() { userService.administrators().stream().map(userData -> userData.username).anyMatch("horreum.bootstrap"::equals), "Bootstrap account missing"); } + + @TestSecurity(user = KEYCLOAK_ADMIN, roles = { Roles.ADMIN }) + @Test + void apiKeys() { + String testTeam = "apikeys-team", apiUser = "api-user"; + userService.addTeam(testTeam); + + // add a user to the team + UserService.NewUser user = new UserService.NewUser(); + user.user = new UserService.UserData("", apiUser, "API", "User", "api@horreum.io"); + user.password = "whatever"; + user.team = testTeam; + user.roles = List.of(Roles.MANAGER); + userService.createUser(user); + + overrideTestSecurity(apiUser, Set.of(Roles.MANAGER, testTeam.substring(0, testTeam.length() - 4) + Roles.MANAGER), + () -> { + assertTrue(userService.apiKeys().isEmpty()); + + // create key + String key = userService.newApiKey(new UserService.ApiKeyRequest("Test key", USER)); + assertFalse(key.length() < 32); // key should be big enough + assertTrue(UserApiKey.findOptional(key).isPresent()); // is persisted + + // one key + List keys = userService.apiKeys(); + assertEquals(1, keys.size()); + assertEquals(DEFAULT_API_KEY_ACTIVE_DAYS, keys.get(0).toExpiration); + assertFalse(keys.get(0).isRevoked); + + // rename key + assertThrows(ServiceException.class, () -> userService.renameApiKey(keys.get(0).id, "horreum.key")); + userService.renameApiKey(keys.get(0).id, "Key with new name"); + assertEquals("Key with new name", userService.apiKeys().get(0).name); + + // the system can find the key by its expiration day + assertTrue( + UserApiKey + . stream("#UserApiKey.expire", + timeService.today().plusDays(DEFAULT_API_KEY_ACTIVE_DAYS)) + .anyMatch(k -> k.id == keys.get(0).id)); + + // about to expire + setApiKeyCreation(keys.get(0).id, timeService.today().minusDays(DEFAULT_API_KEY_ACTIVE_DAYS)); + + // should not be revoked yet + assertFalse(userService.apiKeys().get(0).isRevoked); + assertTrue(UserApiKey. stream("#UserApiKey.expire", timeService.today()) + .anyMatch(k -> k.id == keys.get(0).id)); + + // expire it + setApiKeyCreation(keys.get(0).id, timeService.today().minusDays(DEFAULT_API_KEY_ACTIVE_DAYS + 1)); + + // should be revoked now + assertTrue(userService.apiKeys().get(0).isRevoked); + assertThrows(ServiceException.class, + () -> userService.renameApiKey(keys.get(0).id, "Rename revoked key should throw")); + }); + } + + @Transactional + void setApiKeyCreation(long keyId, LocalDate creation) { + UserApiKey apiKey = UserApiKey.findById(keyId); + apiKey.access = null; + apiKey.creation = creation; + userService.apiKeyDailyTask(); + } } diff --git a/horreum-client/src/main/java/io/hyperfoil/tools/HorreumClient.java b/horreum-client/src/main/java/io/hyperfoil/tools/HorreumClient.java index 1cadd66e5..8f4fc29f4 100644 --- a/horreum-client/src/main/java/io/hyperfoil/tools/HorreumClient.java +++ b/horreum-client/src/main/java/io/hyperfoil/tools/HorreumClient.java @@ -27,6 +27,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; +import io.hyperfoil.tools.auth.HorreumApiKeyAuthentication; import io.hyperfoil.tools.auth.KeycloakClientRequestFilter; import io.hyperfoil.tools.horreum.api.client.RunService; import io.hyperfoil.tools.horreum.api.internal.services.ActionService; @@ -96,6 +97,7 @@ public static class Builder { private String horreumUrl; private String horreumUser; private String horreumPassword; + private String horreumApiKey; private SSLContext sslContext; public Builder() { @@ -116,6 +118,11 @@ public Builder horreumPassword(String horreumPassword) { return this; } + public Builder horreumApiKey(String key) { + this.horreumApiKey = key; + return this; + } + public Builder sslContext(SSLContext sslContext) { this.sslContext = sslContext; return this; @@ -167,7 +174,9 @@ public HorreumClient build() throws IllegalStateException { clientBuilder.register(new CustomResteasyJackson2Provider(), 100); clientBuilder.sslContext(sslContext); - if (keycloakConfig.url == null || keycloakConfig.url.isEmpty()) { + if (horreumApiKey != null) { + clientBuilder.register(new HorreumApiKeyAuthentication(horreumApiKey)); + } else if (keycloakConfig.url == null || keycloakConfig.url.isEmpty()) { clientBuilder.register(new BasicAuthentication(horreumUser, horreumPassword)); } else { // register Keycloak Request Filter diff --git a/horreum-client/src/main/java/io/hyperfoil/tools/auth/HorreumApiKeyAuthentication.java b/horreum-client/src/main/java/io/hyperfoil/tools/auth/HorreumApiKeyAuthentication.java new file mode 100644 index 000000000..c308fbc47 --- /dev/null +++ b/horreum-client/src/main/java/io/hyperfoil/tools/auth/HorreumApiKeyAuthentication.java @@ -0,0 +1,30 @@ +package io.hyperfoil.tools.auth; + +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientRequestFilter; + +import org.jboss.logging.Logger; + +public class HorreumApiKeyAuthentication implements ClientRequestFilter { + + /** sync with {@link io.hyperfoil.tools.horreum.server.ApiKeyAuthenticationMechanism} */ + public static final String HORREUM_AUTHENTICATION_HEADER = "X-Horreum-API-Key"; + + private static final Logger LOG = Logger.getLogger(HorreumApiKeyAuthentication.class); + + private final String authenticationToken; + + private boolean showAuthMethod = true; + + public HorreumApiKeyAuthentication(String token) { + authenticationToken = token; + } + + public void filter(ClientRequestContext requestContext) { + if (showAuthMethod) { + LOG.infov("Authentication with Horreum API key"); + showAuthMethod = false; + } + requestContext.getHeaders().putSingle(HORREUM_AUTHENTICATION_HEADER, authenticationToken); + } +} diff --git a/horreum-integration-tests/src/test/java/io/hyperfoil/tools/horreum/it/HorreumClientIT.java b/horreum-integration-tests/src/test/java/io/hyperfoil/tools/horreum/it/HorreumClientIT.java index 9ba81f7f6..8ffa00b35 100644 --- a/horreum-integration-tests/src/test/java/io/hyperfoil/tools/horreum/it/HorreumClientIT.java +++ b/horreum-integration-tests/src/test/java/io/hyperfoil/tools/horreum/it/HorreumClientIT.java @@ -1,6 +1,10 @@ package io.hyperfoil.tools.horreum.it; +import static io.hyperfoil.tools.horreum.api.internal.services.UserService.KeyType.USER; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -9,6 +13,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.time.Instant; +import java.time.LocalDate; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; @@ -18,6 +23,7 @@ import java.util.stream.Collectors; import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.NotAuthorizedException; import org.junit.jupiter.api.Assertions; @@ -63,6 +69,33 @@ public class HorreumClientIT implements QuarkusTestBeforeTestExecutionCallback, QuarkusTestBeforeClassCallback, QuarkusTestBeforeEachCallback, QuarkusTestAfterEachCallback, QuarkusTestAfterAllCallback { + @org.junit.jupiter.api.Test + public void testApiKeys() { + String keyName = "Test key"; + String theKey = horreumClient.userService.newApiKey(new UserService.ApiKeyRequest(keyName, USER)); + + try (HorreumClient apiClient = new HorreumClient.Builder() + .horreumUrl("http://localhost:".concat(System.getProperty("quarkus.http.test-port"))) + .horreumApiKey(theKey) + .build()) { + + List roles = apiClient.userService.getRoles(); + assertFalse(roles.isEmpty()); + assertTrue(roles.contains(TEST_TEAM.replace("team", Roles.TESTER))); + + UserService.ApiKeyResponse apiKey = horreumClient.userService.apiKeys().get(0); + assertFalse(apiKey.isRevoked); + assertFalse(apiKey.toExpiration < 0); + assertEquals(LocalDate.now(), apiKey.creation); + assertEquals(LocalDate.now(), apiKey.access); + assertEquals(USER, apiKey.type); + + horreumClient.userService.revokeApiKey(apiKey.id); + + assertThrows(NotAuthorizedException.class, apiClient.userService::getRoles); + } + } + @org.junit.jupiter.api.Test public void testAddRunFromData() throws JsonProcessingException { JsonNode payload = new ObjectMapper().readTree(resourceToString("data/config-quickstart.jvm.json")); diff --git a/horreum-web/src/domain/user/ApiKeys.tsx b/horreum-web/src/domain/user/ApiKeys.tsx new file mode 100644 index 000000000..98801b6c0 --- /dev/null +++ b/horreum-web/src/domain/user/ApiKeys.tsx @@ -0,0 +1,228 @@ +import {useContext, useEffect, useState} from "react" +import { + Button, + ClipboardCopy, + Form, + FormGroup, + HelperText, + HelperTextItem, + Label, + Modal, + TextInput, + Tooltip, +} from "@patternfly/react-core" + +import {ApiKeyResponse, userApi} from "../../api"; +import {AppContext} from "../../context/appContext"; +import {AppContextType} from "../../context/@types/appContextTypes"; +import {ActionsColumn, Table, Tbody, Td, Th, Thead, Tr} from "@patternfly/react-table"; + +export default function ApiKeys() { + const {alerting} = useContext(AppContext) as AppContextType; + + const daysAfter = (other: Date) => Math.floor((Date.now() - other.getTime()) / (24 * 3600 * 1000)) + + const [apiKeys, setApiKeys] = useState([]) + const refreshApiKeys = () => userApi.apiKeys().then( + keys => setApiKeys(keys), + error => alerting.dispatchError(error, "FETCH_API_KEYS", "Failed to fetch API keys for user") + ) + useEffect(() => void refreshApiKeys(), []) + + const [createApiKey, setCreateApiKey] = useState(false) + const [newKeyName, setNewKeyName] = useState() + const [newKeyValue, setNewKeyValue] = useState() + + const [renameKeyId, setRenameKeyId] = useState() + const [renameKeyName, setRenameKeyName] = useState() + + const keyTypeTooltip = (key: ApiKeyResponse) => { + switch (key.type) { + case "USER": + return "This key provides the same set of permissions this user has"; + default: + return "Unknown" + } + } + + const keyCreationTooltip = (key: ApiKeyResponse) => { + if (!key.creation) { + return "" + } else { + const d = daysAfter(key.creation) + if (d == 0) { + return "API key was created today" + } else { + return `API key was created ${d} days ago` + } + } + } + + const keyAccessTooltip = (key: ApiKeyResponse) => { + if (!key.access) { + return "API key has never been used" + } else { + const d = daysAfter(key.access) + if (d == 0) { + return "API key was last used today" + } else if (d == 1) { + return "API key was last used yesterday" + } else { + return `API key was last used ${d} days ago` + } + } + } + + const keyStatus = (key: ApiKeyResponse) => { + const labels = []; + if (key.toExpiration != null && key.toExpiration < 0) { + labels.push() + } else if (key.isRevoked) { + labels.push() + } else { + labels.push() + } + if (key.toExpiration != null && key.toExpiration >= 0) { + if (key.toExpiration < 1) { + labels.push() + } else if (key.toExpiration < 2) { + labels.push() + } else if (key.toExpiration < 7) { + labels.push() + } + } + return labels + } + + const performCreateApiKey = () => { + userApi.newApiKey({ + name: newKeyName, + type: "USER" + }) + .then((tokenValue) => { + setNewKeyValue(tokenValue) + void alerting.dispatchInfo("API_KEY_CREATED", "API key created", "API key was successfully created", 3000) + }) + .catch(error => alerting.dispatchError(error, "API_KEY_NOT_CREATED", "Failed to create new API key")) + .finally(() => setCreateApiKey(false)) + .then(() => void refreshApiKeys()) + } + + const performRenameApiKey = () => { + if (renameKeyId) { + userApi.renameApiKey(renameKeyId, renameKeyName) + .then(() => void alerting.dispatchInfo("API_KEY_RENAMED", "API key renamed", "API key was successfully renamed", 3000)) + .catch(error => alerting.dispatchError(error, "API_KEY_NOT_RENAMED", "Failed to rename API key")) + .finally(() => setRenameKeyId(undefined)) + .then(() => void refreshApiKeys()) + } + } + + return ( + <> + + + + + + + + + + + + {apiKeys.map((key) => ( + + + + + + + + + ))} + +
NameTypeCreation dateLast usageStatus
{key.name} + + {key.type} + + + + {key.creation?.toLocaleDateString() || "undefined"} + + + + {key.access?.toLocaleDateString() || } + + {keyStatus(key)} + setRenameKeyId(key.id) + }, + { + title: 'Revoke', + isDisabled: key.isRevoked, + onClick: () => { + if (key.id && confirm(`Are you sure you want to revoke API key '${key.name}'?`)) { + userApi.revokeApiKey(key.id).then( + _ => void refreshApiKeys(), + error => alerting.dispatchError(error, "REVOKE_API_KEY", "Failed to revoke API key") + ) + } + } + } + ]} + /> +
+ + + setCreateApiKey(false)} + actions={[ + , + , + ]} + > +
+ + setNewKeyName(val)}/> + +
+
+ setNewKeyValue(undefined)}> + {newKeyValue} + + + This is the only time you'll be able to see the key + + + + setRenameKeyId(undefined)} + actions={[ + , + , + ]}> +
+ + setRenameKeyName(val)}/> + +
+
+ + ) +} diff --git a/horreum-web/src/domain/user/UserSettings.tsx b/horreum-web/src/domain/user/UserSettings.tsx index 58649ae7c..4f7932856 100644 --- a/horreum-web/src/domain/user/UserSettings.tsx +++ b/horreum-web/src/domain/user/UserSettings.tsx @@ -21,8 +21,10 @@ import { Form, FormGroup, PageSection, - Spinner, EmptyStateHeader, - } from "@patternfly/react-core" + Spinner, + EmptyStateHeader, + Tab, +} from "@patternfly/react-core" import { UserIcon } from "@patternfly/react-icons" @@ -33,7 +35,7 @@ import ManagedTeams from "./ManagedTeams" import {AppContext} from "../../context/appContext"; import {AppContextType} from "../../context/@types/appContextTypes"; import ManageMachineAccounts from "./MachineAccounts"; - +import ApiKeys from "./ApiKeys"; export const UserProfileLink = () => { const profile = useSelector(userProfileSelector) @@ -109,7 +111,7 @@ export function UserSettings() { { setModified(false) - alerting.dispatchInfo("SAVE", "Saved!", "User settings succesfully updated!", 3000) + alerting.dispatchInfo("SAVE", "Saved!", "User settings successfully updated!", 3000) }} afterReset={() => setModified(false)} > @@ -158,7 +160,7 @@ export function UserSettings() { /> { @@ -209,6 +211,9 @@ export function UserSettings() { )} + + + {managedTeams.length > 0 ? (