Implement an alternate JWT security filter.
Change-Id: Ie46b6efc24d045f90f45f7b16f17e4b84ae886c5
diff --git a/.gitignore b/.gitignore
index 7ba2297..0d13c27 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,6 @@
# Unknown
out/
+
+# Credentials
+keys.p12
diff --git a/build.gradle b/build.gradle
index 374b627..3986100 100644
--- a/build.gradle
+++ b/build.gradle
@@ -39,6 +39,7 @@
implementation 'io.quarkus:quarkus-resteasy-jsonb'
implementation 'io.quarkus:quarkus-resteasy-qute'
implementation 'io.quarkus:quarkus-scheduler'
+ implementation 'io.quarkus:quarkus-smallrye-jwt'
//implementation 'io.quarkus:quarkus-elytron-security'
//implementation 'io.quarkus:quarkus-elytron-security-jdbc'
@@ -54,6 +55,8 @@
//implementation 'org.jboss.spec.javax.xml.bind:jboss-jaxb-api_2.3_spec'
//implementation 'jakarta.persistence:jakarta.persistence-api'
+ implementation "org.bitbucket.b_c:jose4j"
+
implementation 'org.mapstruct:mapstruct'
compileOnly 'org.mapstruct:mapstruct-processor'
@@ -76,6 +79,7 @@
compileOnly "com.google.code.findbugs:jsr305:${findbugsJsr305Version}"
implementation "jakarta.security.jacc:jakarta.security.jacc-api:${jakartaJaccVersion}"
implementation "net.java.dev.jna:jna:${jnaVersion}"
+ implementation "org.bitbucket.b_c:jose4j:${jose4jVersion}"
}
}
diff --git a/gradle.properties b/gradle.properties
index f607976..db5af2d 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -22,4 +22,5 @@
findbugsJsr305Version = 3.0.2
jakartaJaccVersion = 1.6.1
jnaVersion = 5.5.0
+jose4jVersion = 0.7.0
testcontainersVersion = 1.12.4
diff --git a/pom.xml b/pom.xml
index a20aed5..844e6d2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -75,6 +75,13 @@
<version>${jakarta-jacc-api.version}</version>
</dependency>
+ <!-- JOSE for Java -->
+ <dependency>
+ <groupId>org.bitbucket.b_c</groupId>
+ <artifactId>jose4j</artifactId>
+ <version>0.7.0</version>
+ </dependency>
+
<!-- MapStruct -->
<dependency>
<groupId>org.mapstruct</groupId>
@@ -167,6 +174,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-scheduler</artifactId>
</dependency>
+ <dependency>
+ <groupId>io.quarkus</groupId>
+ <artifactId>quarkus-smallrye-jwt</artifactId>
+ </dependency>
<!-- JNA -->
<dependency>
@@ -192,6 +203,12 @@
<artifactId>jsoup</artifactId>
</dependency>
+ <!-- JOSE for Java -->
+ <dependency>
+ <groupId>org.bitbucket.b_c</groupId>
+ <artifactId>jose4j</artifactId>
+ </dependency>
+
<!-- MapStruct -->
<dependency>
<groupId>org.mapstruct</groupId>
diff --git a/src/main/java/eu/mulk/mulkcms2/authentication/JwtCookieLoginFilter.java b/src/main/java/eu/mulk/mulkcms2/authentication/JwtCookieLoginFilter.java
new file mode 100644
index 0000000..b39c90a
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/authentication/JwtCookieLoginFilter.java
@@ -0,0 +1,184 @@
+package eu.mulk.mulkcms2.authentication;
+
+import static javax.ws.rs.Priorities.AUTHENTICATION;
+
+import io.quarkus.security.identity.SecurityIdentity;
+import io.smallrye.jwt.auth.AbstractBearerTokenExtractor;
+import io.smallrye.jwt.auth.principal.DefaultJWTCallerPrincipal;
+import io.smallrye.jwt.auth.principal.JWTAuthContextInfo;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.Principal;
+import java.security.PublicKey;
+import java.security.UnrecoverableKeyException;
+import java.security.cert.CertificateException;
+import java.time.Duration;
+import java.util.Objects;
+import javax.annotation.PostConstruct;
+import javax.annotation.Priority;
+import javax.inject.Inject;
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerRequestFilter;
+import javax.ws.rs.core.SecurityContext;
+import javax.ws.rs.ext.Provider;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.eclipse.microprofile.jwt.JsonWebToken;
+import org.jose4j.jwa.AlgorithmConstraints;
+import org.jose4j.jws.AlgorithmIdentifiers;
+import org.jose4j.jwt.MalformedClaimException;
+import org.jose4j.jwt.consumer.InvalidJwtException;
+import org.jose4j.jwt.consumer.JwtConsumerBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Interprets a possibly present JWT cookie and uses it to authenticate the user.
+ *
+ * <p>JWT cookies are used to authenticate further requests based on initial authentication. This
+ * way, there is no need to route the user through an OpenID Connect IdP on each request, for
+ * example.
+ *
+ * @see JwtCookieSetterFilter
+ */
+@Provider
+@Priority(AUTHENTICATION)
+public class JwtCookieLoginFilter implements ContainerRequestFilter {
+
+ @ConfigProperty(name = "mulkcms.jwt.signing-key")
+ String signingKeyAlias;
+
+ @ConfigProperty(name = "mulkcms.jwt.keystore.file")
+ String signingKeyFile;
+
+ @ConfigProperty(name = "mulkcms.jwt.keystore.passphrase")
+ String signingKeyPassphrase;
+
+ @ConfigProperty(name = "mulkcms.jwt.issuer")
+ String issuer;
+
+ @ConfigProperty(name = "mulkcms.jwt.validity")
+ Duration validity;
+
+ @Inject SecurityIdentity identity;
+
+ @Inject JWTAuthContextInfo authContextInfo;
+
+ private static final Logger log = LoggerFactory.getLogger(JwtCookieLoginFilter.class);
+
+ private PublicKey signingKey;
+
+ @PostConstruct
+ public void postCostruct()
+ throws IOException, KeyStoreException, CertificateException, NoSuchAlgorithmException,
+ UnrecoverableKeyException {
+ try (var is = new FileInputStream(signingKeyFile)) {
+ var keystore = KeyStore.getInstance(KeyStore.getDefaultType());
+ keystore.load(is, signingKeyPassphrase.toCharArray());
+ signingKey = keystore.getCertificate(signingKeyAlias).getPublicKey();
+ Objects.requireNonNull(signingKey);
+ }
+ }
+
+ @Override
+ public void filter(ContainerRequestContext requestContext)
+ throws IOException {
+
+ try {
+ if (!identity.isAnonymous()) {
+ log.debug("Already authenticated, skipping JWT check.");
+ return;
+ }
+
+ AbstractBearerTokenExtractor extractor =
+ new BearerTokenExtractor(requestContext, authContextInfo);
+ String bearerToken = extractor.getBearerToken();
+
+ var jwtConsumer =
+ new JwtConsumerBuilder()
+ .setJwsAlgorithmConstraints(
+ new AlgorithmConstraints(
+ AlgorithmConstraints.ConstraintType.WHITELIST,
+ AlgorithmIdentifiers.RSA_USING_SHA256,
+ AlgorithmIdentifiers.RSA_USING_SHA384,
+ AlgorithmIdentifiers.RSA_USING_SHA512,
+ AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256,
+ AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384,
+ AlgorithmIdentifiers.ECDSA_USING_P521_CURVE_AND_SHA512))
+ .setVerificationKey(signingKey)
+ .setRequireExpirationTime()
+ .setAllowedClockSkewInSeconds(60)
+ .build();
+
+ var claims = jwtConsumer.process(bearerToken).getJwtClaims();
+ claims.getSubject();
+
+ var jwtPrincipal = new DefaultJWTCallerPrincipal(claims);
+ log.debug("JWT verified: {}", jwtPrincipal);
+
+ var securityContext =
+ new JwtSecurityContext(requestContext.getSecurityContext(), jwtPrincipal);
+ requestContext.setSecurityContext(securityContext);
+ } catch (InvalidJwtException | MalformedClaimException e) {
+ log.debug("Invalid JWT", e);
+ }
+ }
+
+ private static class BearerTokenExtractor extends AbstractBearerTokenExtractor {
+
+ private final ContainerRequestContext requestContext;
+
+ BearerTokenExtractor(
+ ContainerRequestContext requestContext, JWTAuthContextInfo authContextInfo) {
+ super(authContextInfo);
+ this.requestContext = requestContext;
+ }
+
+ @Override
+ protected String getHeaderValue(String headerName) {
+ return requestContext.getHeaderString(headerName);
+ }
+
+ @Override
+ protected String getCookieValue(String cookieName) {
+ var tokenCookie = requestContext.getCookies().get(cookieName);
+
+ if (tokenCookie != null) {
+ return tokenCookie.getValue();
+ }
+ return null;
+ }
+ }
+
+ private static class JwtSecurityContext implements SecurityContext {
+ private SecurityContext delegate;
+ private JsonWebToken principal;
+
+ JwtSecurityContext(SecurityContext delegate, JsonWebToken principal) {
+ this.delegate = delegate;
+ this.principal = principal;
+ }
+
+ @Override
+ public Principal getUserPrincipal() {
+ return principal;
+ }
+
+ @Override
+ public boolean isUserInRole(String role) {
+ return principal.getGroups().contains(role);
+ }
+
+ @Override
+ public boolean isSecure() {
+ return delegate.isSecure();
+ }
+
+ @Override
+ public String getAuthenticationScheme() {
+ return delegate.getAuthenticationScheme();
+ }
+ }
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/authentication/JwtCookieSetterFilter.java b/src/main/java/eu/mulk/mulkcms2/authentication/JwtCookieSetterFilter.java
new file mode 100644
index 0000000..baa51d4
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/authentication/JwtCookieSetterFilter.java
@@ -0,0 +1,118 @@
+package eu.mulk.mulkcms2.authentication;
+
+import io.quarkus.security.identity.SecurityIdentity;
+import io.smallrye.jwt.build.Jwt;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.UnrecoverableKeyException;
+import java.security.cert.CertificateException;
+import java.time.Duration;
+import javax.annotation.PostConstruct;
+import javax.annotation.Priority;
+import javax.inject.Inject;
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerResponseContext;
+import javax.ws.rs.container.ContainerResponseFilter;
+import javax.ws.rs.core.NewCookie;
+import javax.ws.rs.ext.Provider;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.eclipse.microprofile.jwt.Claims;
+import org.eclipse.microprofile.jwt.JsonWebToken;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Adds a JWT cookie to every authenticated request.
+ *
+ * <p>JWT cookies are used to authenticate further requests based on initial authentication. This
+ * way, there is no need to route the user through an OpenID Connect IdP on each request, for
+ * example.
+ *
+ * @see JwtCookieLoginFilter
+ */
+@Provider
+@Priority(1100)
+public class JwtCookieSetterFilter implements ContainerResponseFilter {
+
+ @ConfigProperty(name = "mulkcms.jwt.signing-key")
+ String signingKeyAlias;
+
+ @ConfigProperty(name = "mulkcms.jwt.keystore.file")
+ String signingKeyFile;
+
+ @ConfigProperty(name = "mulkcms.jwt.keystore.passphrase")
+ String signingKeyPassphrase;
+
+ @ConfigProperty(name = "mulkcms.jwt.issuer")
+ String issuer;
+
+ @ConfigProperty(name = "mulkcms.jwt.validity")
+ Duration validity;
+
+ @Inject SecurityIdentity identity;
+
+ private static final Logger log = LoggerFactory.getLogger(JwtCookieSetterFilter.class);
+
+ private PrivateKey signingKey;
+
+ @PostConstruct
+ public void postCostruct()
+ throws IOException, KeyStoreException, CertificateException, NoSuchAlgorithmException,
+ UnrecoverableKeyException {
+ try (var is = new FileInputStream(signingKeyFile)) {
+ var keystore = KeyStore.getInstance(KeyStore.getDefaultType());
+ keystore.load(is, signingKeyPassphrase.toCharArray());
+ signingKey =
+ (PrivateKey) keystore.getKey(signingKeyAlias, signingKeyPassphrase.toCharArray());
+ }
+ }
+
+ @Override
+ public void filter(
+ ContainerRequestContext requestContext, ContainerResponseContext responseContext)
+ throws IOException {
+
+ if (identity.isAnonymous()) {
+ return;
+ }
+
+ var currentTimeSeconds = System.currentTimeMillis() / 1000;
+
+ if (identity instanceof JsonWebToken
+ && ((JsonWebToken) identity).getExpirationTime() < currentTimeSeconds) {
+ return;
+ }
+
+ var claims = Jwt.claims();
+
+ claims.issuedAt(currentTimeSeconds);
+ claims.claim(Claims.auth_time.name(), currentTimeSeconds);
+ claims.expiresAt(currentTimeSeconds + validity.toSeconds());
+ claims.issuer(issuer);
+ claims.preferredUserName(identity.getPrincipal().getName());
+ claims.subject(identity.getPrincipal().getName());
+
+ var token = claims.jws().signatureKeyId(signingKeyAlias).sign(signingKey);
+ responseContext
+ .getHeaders()
+ .add(
+ "Set-Cookie",
+ new NewCookie(
+ "Bearer",
+ token,
+ null,
+ null,
+ 1,
+ null,
+ (int) validity.toSeconds(),
+ null,
+ false,
+ true)
+ .toString()
+ + ";SameSite=Strict");
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 994d3b0..833aa45 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -34,6 +34,20 @@
%dev.quarkus.security.users.embedded.users.mulk = mulk
%dev.quarkus.security.users.embedded.roles.mulk = Admin
+# Session cookies
+quarkus.smallrye-jwt.enabled = false
+mp.jwt.verify.publickey.location = META-INF/resources/jwt-signing-public-key.pem
+mp.jwt.verify.issuer = https://matthias.benkard.de
+smallrye.jwt.token.header = Cookie
+smallrye.jwt.token.cookie = Bearer
+smallrye.jwt.require.named-principal = true
+%dev.mulkcms.jwt.keystore.file = example-keys.p12
+%prod.mulkcms.jwt.keystore.file = /secrets/keys.p12
+mulkcms.jwt.keystore.passphrase = 123456
+mulkcms.jwt.signing-key = MulkCMS-IdP
+mulkcms.jwt.issuer = https://matthias.benkard.de
+mulkcms.jwt.validity = P1D
+
# Deployment
docker.registry = docker.benkard.de
@@ -48,3 +62,12 @@
kubernetes.env-vars[0].name = QUARKUS_DATASOURCE_PASSWORD
kubernetes.env-vars[0].secret = mulkcms2-secrets
kubernetes.env-vars[0].value = database-password
+kubernetes.env-vars[1].name = QUARKUS_OIDC_CREDENTIALS_SECRET
+kubernetes.env-vars[1].secret = mulkcms2-secrets
+kubernetes.env-vars[1].value = keycloak-secret
+kubernetes.secret-volumes[0].volume-name = secrets
+kubernetes.secret-volumes[0].secret-name = mulkcms2-secrets
+kubernetes.secret-volumes[0].default-mode = 0444
+kubernetes.mounts[0].name = secrets
+kubernetes.mounts[0].path = /secrets
+kubernetes.mounts[0].read-only = true
diff --git a/src/main/resources/example-keys.p12 b/src/main/resources/example-keys.p12
new file mode 100644
index 0000000..d3a7acb
--- /dev/null
+++ b/src/main/resources/example-keys.p12
Binary files differ