Implement an alternate JWT security filter.

Change-Id: Ie46b6efc24d045f90f45f7b16f17e4b84ae886c5
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