-
Notifications
You must be signed in to change notification settings - Fork 31
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
23 changed files
with
992 additions
and
60 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
164 changes: 164 additions & 0 deletions
164
horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserApiKey.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
63 changes: 63 additions & 0 deletions
63
...ackend/src/main/java/io/hyperfoil/tools/horreum/server/ApiKeyAuthenticationMechanism.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
} |
Oops, something went wrong.