diff options
-rw-r--r-- | .gitignore | 3 | ||||
-rw-r--r-- | build.gradle | 4 | ||||
-rw-r--r-- | gradle.properties | 1 | ||||
-rw-r--r-- | pom.xml | 17 | ||||
-rw-r--r-- | src/main/java/eu/mulk/mulkcms2/authentication/JwtCookieLoginFilter.java | 184 | ||||
-rw-r--r-- | src/main/java/eu/mulk/mulkcms2/authentication/JwtCookieSetterFilter.java | 118 | ||||
-rw-r--r-- | src/main/resources/application.properties | 23 | ||||
-rw-r--r-- | src/main/resources/example-keys.p12 | bin | 0 -> 1070 bytes |
8 files changed, 350 insertions, 0 deletions
@@ -47,3 +47,6 @@ web_modules # 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 @@ dependencies { 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 @@ dependencies { //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 @@ dependencies { 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 @@ mapstructVersion = 1.3.1.Final findbugsJsr305Version = 3.0.2 jakartaJaccVersion = 1.6.1 jnaVersion = 5.5.0 +jose4jVersion = 0.7.0 testcontainersVersion = 1.12.4 @@ -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 @@ quarkus.security.users.embedded.enabled = false %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.service-account = default 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 Binary files differnew file mode 100644 index 0000000..d3a7acb --- /dev/null +++ b/src/main/resources/example-keys.p12 |