Skip to content

Commit

Permalink
API keys feature
Browse files Browse the repository at this point in the history
  • Loading branch information
barreiro committed Sep 10, 2024
1 parent 69fdfe8 commit b0ed068
Show file tree
Hide file tree
Showing 23 changed files with 992 additions and 60 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.hyperfoil.tools.horreum.api.internal.services;

import java.time.LocalDate;
import java.util.List;
import java.util.Map;

Expand All @@ -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;
Expand Down Expand Up @@ -118,6 +120,28 @@ List<UserData> 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<ApiKeyResponse> 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
Expand Down Expand Up @@ -146,4 +170,35 @@ class NewUser {
public String team;
public List<String> 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;
}

}
Original file line number Diff line number Diff line change
@@ -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<UserApiKey> {

// 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<UserApiKey> 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.<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.<UserApiKey, LocalDate> comparing(a -> a.creation).thenComparing(a -> a.id).compare(this, other);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,18 @@ public class UserInfo extends PanacheEntityBase {
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
public Set<TeamMembership> teams;

@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
public Set<UserApiKey> apiKeys;

public UserInfo() {
}

public UserInfo(String username) {
this.username = username;
this.roles = new HashSet<>();
this.teams = new HashSet<>();
this.apiKeys = new HashSet<>();
}

public void setPassword(String clearPassword) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);

}
Original file line number Diff line number Diff line change
@@ -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<SecurityIdentity> 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<ChallengeData> getChallenge(RoutingContext context) {
return Uni.createFrom().item(new ChallengeData(HttpResponseStatus.UNAUTHORIZED.code(), null, null));
}

@Override
public Set<Class<? extends AuthenticationRequest>> getCredentialTypes() {
return Collections.singleton(Request.class);
}

@Override
public Uni<HttpCredentialTransport> 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;
}
}
}
Loading

0 comments on commit b0ed068

Please sign in to comment.