Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Auth Token feature #1952

Merged
merged 2 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.hyperfoil.tools.horreum.api.internal.services;

import java.time.Instant;
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 Instant creation;
public Instant access;
public boolean isRevoked;
public long toExpiration;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package io.hyperfoil.tools.horreum.entity.user;

import static jakarta.persistence.GenerationType.SEQUENCE;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
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 trunc(creation + (active day), day) = trunc(cast(?1 as localdatetime), day)
or trunc(access + (active day), day) = trunc(cast(?1 as localdatetime), day))
"""),
// 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 trunc(creation + (active day), day) < trunc(cast(?1 as localdatetime), day)
or trunc(access + (active day), day) < trunc(cast(?1 as localdatetime), day))
"""),
})
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;
barreiro marked this conversation as resolved.
Show resolved Hide resolved

public String name;

@Enumerated
public final UserService.KeyType type;

public Instant 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(String name, UserService.KeyType type, Instant creationDate, long valid) {
randomnessSource = UUID.randomUUID();
this.name = name;
this.type = type;
this.active = valid;
hash = computeHash(keyString());
creation = creationDate;
revoked = false;
}

public boolean isArchived(Instant givenDay) {
return givenDay.isAfter((access == null ? creation : access).plus(active + ARCHIVE_AFTER_DAYS, ChronoUnit.DAYS));
}

// calculate the number of days left until expiration (if negative it's the number of days after expiration)
public long toExpiration(Instant givenDay) {
return active - ChronoUnit.DAYS.between(access == null ? creation : access, givenDay);
}

public String keyString() {
String typeStr = switch (type) {
case USER -> "USR";
};
return "H" + typeStr + "_" + randomnessSource.toString().replace("-", "_").toUpperCase(); // keep the dashes for quick validation of key format
}

// 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();
}
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, Instant> 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
@@ -0,0 +1,25 @@
package io.hyperfoil.tools.horreum.mapper;

import java.time.Instant;

import io.hyperfoil.tools.horreum.api.internal.services.UserService;
import io.hyperfoil.tools.horreum.entity.user.UserApiKey;

public class UserApiKeyMapper {

public static UserApiKey from(UserService.ApiKeyRequest request, Instant creation, long valid) {
return new UserApiKey(request.name == null ? "" : request.name, request.type, creation, valid);
}

public static UserService.ApiKeyResponse to(UserApiKey key) {
UserService.ApiKeyResponse response = new UserService.ApiKeyResponse();
response.id = key.id;
response.name = key.name;
response.type = key.type;
response.creation = key.creation;
response.access = key.access;
response.isRevoked = key.revoked;
response.toExpiration = key.toExpiration(Instant.now());
return response;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.text.SimpleDateFormat;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;

import jakarta.enterprise.context.ApplicationScoped;
Expand Down Expand Up @@ -46,6 +47,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 @@ -150,6 +154,24 @@ public void notifyExpectedRun(String testName, int testId, long before, String e
Log.debug("Sending mail: " + content);
});
}

@Override
public void notifyApiKeyExpiration(String keyName, Instant creation, Instant 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.truncatedTo(ChronoUnit.DAYS))
.data("lastAccess", lastAccess.truncatedTo(ChronoUnit.DAYS))
.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
Expand Up @@ -22,4 +22,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, Instant creation, Instant lastAccess, long toExpiration,
long active);
barreiro marked this conversation as resolved.
Show resolved Hide resolved

}
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 ApiKeyAuthenticationMechanism.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(ApiKeyAuthenticationMechanism.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