diff options
Diffstat (limited to 'src/main')
| -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 | 
4 files changed, 325 insertions, 0 deletions
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  | 
