refactor(ext-oidc): replace auth modules with security extensions
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# flash-ext-security-apikey
|
||||
|
||||
API keys for [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md), sent as
|
||||
`Authorization: Bearer <prefix>_<id>.<secret>`.
|
||||
|
||||
```java
|
||||
ApiKeyExtension<Grant> apiKeys = new ApiKeyExtension<>("gk", id -> rows.find(id)); // ApiKeyStore<Grant>
|
||||
app.install(new SecurityExtension().roles(...)).install(apiKeys);
|
||||
|
||||
GeneratedApiKey key = apiKeys.generate(); // show key.token() once
|
||||
rows.save(key.id(), key.secretHash(), grant); // never the token
|
||||
```
|
||||
|
||||
The store returns `ApiKey<G>(id, secretHash, grant, expiresAt, revokedAt)`; `G` is whatever the
|
||||
application authorizes on. An authenticated caller is an `ApiKeyPrincipal<G>` carrying that grant —
|
||||
read it in a `RoleResolver` with `identity.principal(ApiKeyPrincipal.class)`.
|
||||
|
||||
A bearer token without this prefix is left to other mechanisms; one with it that fails — unknown id,
|
||||
wrong secret, expired, revoked — is a `401 invalid_token`. Only a SHA-256 of the secret is stored: the
|
||||
secret is 192 random bits, so a slow KDF would protect nothing and cost every request.
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-security-apikey</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-security-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.relism.flash.ext.security.apikey;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* An API key as the application stores it: never the secret, only its hash, and the grant it was
|
||||
* issued with — whatever the application authorizes on.
|
||||
*
|
||||
* @param expiresAt {@code null} for a key that does not expire
|
||||
* @param revokedAt {@code null} for a key that has not been revoked
|
||||
*/
|
||||
public record ApiKey<G>(String id, String secretHash, G grant, Instant expiresAt, Instant revokedAt) {
|
||||
|
||||
boolean isActive() {
|
||||
return revokedAt == null && (expiresAt == null || expiresAt.toEpochMilli() > System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package dev.relism.flash.ext.security.apikey;
|
||||
|
||||
import dev.relism.flash.ext.security.AuthenticationFailedException;
|
||||
import dev.relism.flash.ext.security.AuthenticationMechanism;
|
||||
import dev.relism.flash.ext.security.Principal;
|
||||
import dev.relism.flash.ext.security.SecurityExtension;
|
||||
import dev.relism.flash.ext.security.SecurityScheme;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.models.Request;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* API keys sent as {@code Authorization: Bearer <prefix>_<id>.<secret>}. The prefix makes a key
|
||||
* recognisable at a glance and to secret scanners; the id is what the store is queried by; only a
|
||||
* SHA-256 of the secret is ever stored — a KDF would add nothing to 192 random bits.
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.install(new SecurityExtension())
|
||||
* .install(new ApiKeyExtension<>("gk", keys::find));
|
||||
* }</pre>
|
||||
*/
|
||||
public final class ApiKeyExtension<G> implements FlashExtension, AuthenticationMechanism {
|
||||
|
||||
private static final AuthenticationFailedException INVALID = new AuthenticationFailedException("Bearer error=\"invalid_token\"");
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final Base64.Encoder BASE64URL = Base64.getUrlEncoder().withoutPadding();
|
||||
|
||||
private final String prefix;
|
||||
private final String bearer;
|
||||
private final ApiKeyStore<G> store;
|
||||
|
||||
/** @param prefix identifies this application's keys; letters and digits only */
|
||||
public ApiKeyExtension(String prefix, ApiKeyStore<G> store) {
|
||||
if (!prefix.matches("[A-Za-z0-9]+")) throw new IllegalArgumentException("API key prefix must be alphanumeric: " + prefix);
|
||||
this.prefix = prefix;
|
||||
this.bearer = "Bearer " + prefix + "_";
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
/** A new key: 72 bits of id, 192 bits of secret. */
|
||||
public GeneratedApiKey generate() {
|
||||
String id = random(9);
|
||||
String secret = random(24);
|
||||
return new GeneratedApiKey(id, prefix + "_" + id + "." + secret, hash(secret));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Principal authenticate(Request req) {
|
||||
String header = req.header("Authorization");
|
||||
if (header == null || !header.startsWith(bearer)) return null;
|
||||
int dot = header.indexOf('.', bearer.length());
|
||||
if (dot < 0) throw INVALID;
|
||||
ApiKey<G> key = store.find(header.substring(bearer.length(), dot));
|
||||
if (key == null || !matches(header.substring(dot + 1), key.secretHash()) || !key.isActive()) throw INVALID;
|
||||
return new ApiKeyPrincipal<>(key.id(), key.grant());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SecurityScheme scheme() {
|
||||
return SecurityScheme.bearer("apiKey", prefix + "_<id>.<secret>");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.provide(ApiKeyExtension.class, this);
|
||||
ctx.onReady(() -> ctx.require(SecurityExtension.class).mechanism(this));
|
||||
}
|
||||
|
||||
private static boolean matches(String secret, String secretHash) {
|
||||
return MessageDigest.isEqual(digest(secret), Base64.getUrlDecoder().decode(secretHash));
|
||||
}
|
||||
|
||||
private static String hash(String secret) {
|
||||
return BASE64URL.encodeToString(digest(secret));
|
||||
}
|
||||
|
||||
private static byte[] digest(String secret) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.US_ASCII));
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException(impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static String random(int bytes) {
|
||||
byte[] value = new byte[bytes];
|
||||
RANDOM.nextBytes(value);
|
||||
return BASE64URL.encodeToString(value);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package dev.relism.flash.ext.security.apikey;
|
||||
|
||||
import dev.relism.flash.ext.security.Principal;
|
||||
|
||||
/** A caller authenticated by an API key, carrying the grant the key was issued with. */
|
||||
public record ApiKeyPrincipal<G>(String name, G grant) implements Principal {}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.relism.flash.ext.security.apikey;
|
||||
|
||||
/** Where the application keeps its API keys. */
|
||||
@FunctionalInterface
|
||||
public interface ApiKeyStore<G> {
|
||||
|
||||
/** The key with this id, or {@code null}. */
|
||||
ApiKey<G> find(String id);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.relism.flash.ext.security.apikey;
|
||||
|
||||
/**
|
||||
* A freshly generated key. {@code token} goes to the caller exactly once; the application stores
|
||||
* {@code id} and {@code secretHash}, never the token.
|
||||
*/
|
||||
public record GeneratedApiKey(String id, String token, String secretHash) {}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.relism.flash.ext.security.apikey;
|
||||
|
||||
import dev.relism.flash.ext.security.SecurityExtension;
|
||||
import dev.relism.flash.ext.security.SecurityIdentity;
|
||||
import dev.relism.flash.ext.security.SecurityPolicy;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
class ApiKeyExtensionTest {
|
||||
|
||||
static final Map<String, ApiKey<String>> KEYS = new ConcurrentHashMap<>();
|
||||
static final SecurityExtension security = new SecurityExtension();
|
||||
static final ApiKeyExtension<String> apiKeys = new ApiKeyExtension<>("fk", KEYS::get);
|
||||
|
||||
@RegisterExtension
|
||||
static final FlashTest app = FlashTest.of(flash -> flash
|
||||
.install(security)
|
||||
.install(apiKeys)
|
||||
.get("/grant", (req, res) -> SecurityIdentity.current().principal(ApiKeyPrincipal.class).grant(),
|
||||
security.enforce(SecurityPolicy.AUTHENTICATED)));
|
||||
|
||||
static String issue(String grant, Instant expiresAt, Instant revokedAt) {
|
||||
GeneratedApiKey key = apiKeys.generate();
|
||||
KEYS.put(key.id(), new ApiKey<>(key.id(), key.secretHash(), grant, expiresAt, revokedAt));
|
||||
return "Bearer " + key.token();
|
||||
}
|
||||
|
||||
@Test
|
||||
void anIssuedKeyAuthenticatesWithItsGrant() {
|
||||
app.request().header("Authorization", issue("project-42", null, null)).get("/grant").expectStatus(200).expectBody("project-42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWrongSecretAnExpiredKeyAndARevokedKeyAreRejectedAsInvalid() {
|
||||
String valid = issue("x", null, null);
|
||||
for (String token : new String[]{
|
||||
valid.substring(0, valid.length() - 1) + (valid.endsWith("A") ? "B" : "A"),
|
||||
issue("x", Instant.now().minusSeconds(1), null),
|
||||
issue("x", null, Instant.now()),
|
||||
"Bearer fk_no-secret-here"}) {
|
||||
app.request().header("Authorization", token).get("/grant")
|
||||
.expectStatus(401).expectHeader("WWW-Authenticate", "Bearer error=\"invalid_token\"");
|
||||
}
|
||||
}
|
||||
|
||||
/** Another application's bearer token is not a key of ours: it is left to other mechanisms, not rejected. */
|
||||
@Test
|
||||
void aForeignBearerTokenIsNotThisMechanisms() {
|
||||
app.request().header("Authorization", "Bearer eyJhbGciOi.payload.signature").get("/grant")
|
||||
.expectStatus(401).expectHeader("WWW-Authenticate", "Bearer realm=\"apiKey\"");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user