diff --git a/docs/docs/development.md b/docs/docs/development.md index d3e3691a..a36344a1 100644 --- a/docs/docs/development.md +++ b/docs/docs/development.md @@ -79,8 +79,6 @@ java -jar $JETTY_HOME/start.jar The webservice will run on port **8080**, and you will see the data you inserted -[Docker Compose]: https://docs.docker.com/compose/ - [jcabi-mysql]: https://mysql.jcabi.com/ [Testcontainers]: https://qubitpi.github.io/testcontainers-java/ diff --git a/docs/docs/img/oauth2-filtering.drawio b/docs/docs/img/oauth2-filtering.drawio new file mode 100644 index 00000000..4655cdc2 --- /dev/null +++ b/docs/docs/img/oauth2-filtering.drawio @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/docs/img/oauth2-filtering.png b/docs/docs/img/oauth2-filtering.png new file mode 100644 index 00000000..1d2eadf4 Binary files /dev/null and b/docs/docs/img/oauth2-filtering.png differ diff --git a/docs/docs/security.md b/docs/docs/security.md new file mode 100644 index 00000000..de1acb5e --- /dev/null +++ b/docs/docs/security.md @@ -0,0 +1,219 @@ +--- +sidebar_position: 5 +title: Security +--- + +Core Concepts +------------- + +API authentication is largely a solved problem and generally outside the scope of Jersey WS Template. + +Jersey WS Template does, however, adds a layer of security on its own by validating [OAuth 2 access token] on all +incoming request. Each API request requires a standard **"Authentication": "Bearer "** token header: + +![Error loading oauth2-filtering.png](./img/oauth2-filtering.png) + +To define a token validator, simply implement the **AccessTokenValidator** like so: + +```java +public class JwtTokenValidator implements AccessTokenValidator { + + @Override + public boolean validate(@NotNull final String accessToken) { + // use https://github.com/auth0/java-jwt as recommended by https://jwt.io/ + return true; + } +} +``` + +and bind the validator, in [BinderFactory], using + +```java +bind(new JwtTokenValidator()).to(AccessTokenValidator.class); +``` + +For example, to validate a ES384 token using _AccessTokenValidator_, we may implement a _ES384JwtTokenValidator_ +validator below: + +:::note + +Note that the implementation below depends on 2 JWT libraries: + +```xml + + com.auth0 + java-jwt + 4.4.0 + + + com.auth0 + jwks-rsa + 0.22.1 + +``` + +::: + +```java +import com.auth0.jwk.InvalidPublicKeyException; +import com.auth0.jwk.Jwk; +import com.auth0.jwk.JwkException; +import com.auth0.jwk.JwkProvider; +import com.auth0.jwk.JwkProviderBuilder; +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.Verification; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.validation.constraints.NotNull; +import net.jcip.annotations.Immutable; +import net.jcip.annotations.ThreadSafe; + +import java.net.MalformedURLException; +import java.net.URL; +import java.security.interfaces.ECPublicKey; +import java.util.Objects; + +/** + * {@link ES384JwtTokenValidator} validates an JWT token in ES384 JWS form. + * + * It validates the access token by verifying the integrity of the header and payload to ensure that they have not been + * altered by using token's signature section. + */ +@Immutable +@ThreadSafe +public class ES384JwtTokenValidator implements AccessTokenValidator { + + private static final Logger LOG = LoggerFactory.getLogger(ES384JwtTokenValidator.class); + + private final String jwksUrl; + + /** + * Constructs a new {@link ES384JwtTokenValidator} that verfies the signed JWT token with the JWK keys stored at a + * specified URL. + * + * @param jwksUrl The provided JWKS URL that, on GET, returns a json object such as + *
+     * {@code
+     * {
+     *     "keys": [
+     *         {
+     *             "kty": "EC",
+     *             "use": "sig",
+     *             "kid": "eTERknhur9q8gisdaf_dfrqrgdfsg",
+     *             "alg": "ES384",
+     *             "crv": "P-384",
+     *             "x": "sdfrgHGYF...",
+     *             "y": "sdfuUIG&8..."
+     *         }
+     *     ]
+     * }
+     * }
+     * 
+ * + * @throws NullPointerException if {@code jwksUrl} is {@code null} + */ + public ES384JwtTokenValidator(final String jwksUrl) { + this.jwksUrl = Objects.requireNonNull(jwksUrl); + } + + @Override + public boolean validate(@NotNull final String accessToken) { + final JwkProvider jwkProvider = getJwkProvider(getJwksUrl()); + final Jwk jwk = getJwk(jwkProvider, accessToken); + final Algorithm algorithm = getVerificationAlgorithm(jwk); + final Verification verifier = JWT.require(algorithm); + + verifier.build().verify(accessToken); + + return true; + } + + /** + * Returns a one-time built instance of {@link JwkProvider} to save performance. + * + * @param jwksUrl The JWKS URL that, on GET, returns a json object such as + *
+     * {@code
+     * {
+     *     "keys": [
+     *         {
+     *             "kty": "EC",
+     *             "use": "sig",
+     *             "kid": "eTERknhur9q8gisdaf_dfrqrgdfsg",
+     *             "alg": "ES384",
+     *             "crv": "P-384",
+     *             "x": "sdfrgHGYF...",
+     *             "y": "sdfuUIG&8..."
+     *         }
+     *     ]
+     * }
+     * }
+     * 
+ * + * @return a new instance + * + * @throws IllegalStateException if the {@code jwksUrl} is an invalid URL + */ + @NotNull + private static JwkProvider getJwkProvider(@NotNull final String jwksUrl) { + try { + return new JwkProviderBuilder(new URL(jwksUrl)).build(); + } catch (final MalformedURLException exception) { + final String message = String.format("Invalid JWKS URL: '%s'", jwksUrl); + LOG.error(message, exception); + throw new IllegalStateException(message, exception); + } + } + + /** + * Returns a JWK key set that will be used to verify a given JWT access token. + * + * @param jwkProvider An object that contains the JWK; cannot be {@code null} + * @param accessToken A "key" that indexes the JWK from the {@code jwkProvider}; cannot be {@code null} + * + * @return a JWK key set + * + * @throws IllegalStateException if no JWK set associated with the provided token is found from {@code jwkProvider} + */ + private static Jwk getJwk(@NotNull final JwkProvider jwkProvider, @NotNull final String accessToken) { + try { + return jwkProvider.get(JWT.decode(accessToken).getKeyId()); + } catch (final JwkException exception) { + final String message = "The key ID in the access token does not match any JWK"; + LOG.error(message, exception); + throw new IllegalStateException(message, exception); + } + } + + /** + * Returns the verifying algorithm associated with a specified JWK. + * + * @param jwk The JWK contains the public key for decrypting the token signature, cannot be {@code null} + * + * @return a decrypting algorithm with public key and without private key enclosed + * + * @throws IllegalStateException if public key cannot be retrieved from the JWK + */ + private static Algorithm getVerificationAlgorithm(@NotNull final Jwk jwk) { + try { + return Algorithm.ECDSA384((ECPublicKey) jwk.getPublicKey(), null); + } catch (final InvalidPublicKeyException exception) { + final String message = "The public key cannot be build from JWK"; + LOG.error(message, exception); + throw new IllegalStateException(message, exception); + } + } + + @NotNull + private String getJwksUrl() { + return jwksUrl; + } +} +``` + +[BinderFactory]: https://github.com/QubitPi/jersey-ws-template/blob/master/src/main/java/com/qubitpi/ws/jersey/template/application/BinderFactory.java + +[OAuth 2 access token]: https://www.oauth.com/oauth2-servers/access-tokens/ diff --git a/pom.xml b/pom.xml index b6aed46f..39396b93 100644 --- a/pom.xml +++ b/pom.xml @@ -41,7 +41,7 @@ 1.7.25 1.2.3 2.13.3 - 1.1.2 + 1.0.4 6.0.0 3.1.1 4.0.6 @@ -509,12 +509,4 @@ - - - - paion - Paion Data Official Release Repository - https://nexus.paion-data.dev/repository/maven-oss - - diff --git a/src/main/java/com/qubitpi/ws/jersey/template/application/BinderFactory.java b/src/main/java/com/qubitpi/ws/jersey/template/application/BinderFactory.java index d80c1121..f2f9fa9d 100644 --- a/src/main/java/com/qubitpi/ws/jersey/template/application/BinderFactory.java +++ b/src/main/java/com/qubitpi/ws/jersey/template/application/BinderFactory.java @@ -15,6 +15,8 @@ */ package com.qubitpi.ws.jersey.template.application; +import com.qubitpi.ws.jersey.template.web.filters.oauth.AccessTokenValidator; + import org.glassfish.hk2.utilities.Binder; import org.glassfish.hk2.utilities.binding.AbstractBinder; @@ -45,7 +47,12 @@ public Binder buildBinder() { return new AbstractBinder() { @Override protected void configure() { - // intentionally left blank + bind(new AccessTokenValidator() { + @Override + public boolean validate(final String accessToken) { + return true; + } + }).to(AccessTokenValidator.class); } }; } diff --git a/src/main/java/com/qubitpi/ws/jersey/template/application/ResourceConfig.java b/src/main/java/com/qubitpi/ws/jersey/template/application/ResourceConfig.java index d6f2ee43..5889ac4f 100644 --- a/src/main/java/com/qubitpi/ws/jersey/template/application/ResourceConfig.java +++ b/src/main/java/com/qubitpi/ws/jersey/template/application/ResourceConfig.java @@ -16,9 +16,9 @@ package com.qubitpi.ws.jersey.template.application; import com.qubitpi.ws.jersey.template.web.filters.CorsFilter; +import com.qubitpi.ws.jersey.template.web.filters.OAuthFilter; import org.glassfish.hk2.utilities.Binder; -import org.glassfish.jersey.media.multipart.MultiPartFeature; import jakarta.inject.Inject; import jakarta.ws.rs.ApplicationPath; @@ -45,6 +45,6 @@ public ResourceConfig() { packages(ENDPOINT_PACKAGE); register(new CorsFilter()); register(binder); - register(MultiPartFeature.class); + register(OAuthFilter.class); } } diff --git a/src/main/java/com/qubitpi/ws/jersey/template/web/filters/OAuthFilter.java b/src/main/java/com/qubitpi/ws/jersey/template/web/filters/OAuthFilter.java new file mode 100644 index 00000000..8567a8e2 --- /dev/null +++ b/src/main/java/com/qubitpi/ws/jersey/template/web/filters/OAuthFilter.java @@ -0,0 +1,116 @@ +/* + * Copyright Jiaqi Liu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.qubitpi.ws.jersey.template.web.filters; + +import com.qubitpi.ws.jersey.template.web.filters.oauth.AccessTokenValidator; + +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; +import net.jcip.annotations.Immutable; +import net.jcip.annotations.ThreadSafe; + +import java.io.IOException; +import java.util.Objects; + +@Provider +@Immutable +@ThreadSafe +@Priority(Priorities.AUTHENTICATION) +public class OAuthFilter implements ContainerRequestFilter { + + /** + * The header key for OAuth 2 access token. + */ + public static final String AUTHORIZATION_HEADER = "Authorization"; + + /** + * The token scheme. + */ + public static final String AUTHORIZATION_SCHEME = "Bearer"; + + private final AccessTokenValidator accessTokenValidator; + + /** + * DI constructor. + * + * @param accessTokenValidator An abstraction layer responsible for validating an OAuth2 access token + */ + @Inject + public OAuthFilter(final AccessTokenValidator accessTokenValidator) { + this.accessTokenValidator = Objects.requireNonNull(accessTokenValidator); + } + + @Override + public void filter(final ContainerRequestContext containerRequestContext) throws IOException { + if (!containerRequestContext.getHeaders().containsKey(AUTHORIZATION_HEADER)) { + containerRequestContext.abortWith( + Response.status(Response.Status.UNAUTHORIZED).entity("Authorization header is missing").build() + ); + return; + } + + final String accessToken = getAccessToken(containerRequestContext); + + if (!isValidToken(accessToken)) { + containerRequestContext.abortWith( + Response.status(Response.Status.UNAUTHORIZED).entity("Invalid access token").build() + ); + } + } + + /** + * Retrieves the access token from container request context. + * + * For example, when an HTTP request comes with header "Authorization": "Bearer 43rgfgef43ewfg4gergeg43g34g", this + * method returns "43rgfgef43ewfg4gergeg43g34g". + * + * @param containerRequestContext The request context that is ASSUMED to contain the "Authorization" header + * + * @return the bare access token + */ + @NotNull + private static String getAccessToken(@NotNull final ContainerRequestContext containerRequestContext) { + return containerRequestContext + .getHeaders() + .get(AUTHORIZATION_HEADER) + .get(0) + .replaceFirst(AUTHORIZATION_SCHEME + " ", ""); + } + + /** + * Returns whether or not a specified access token is valid. + * + * @param accessToken The token to validate + * + * @return {@code true} if the token is valid or {@code false} otherwise + * + * @throws NullPointerException if {@code accessToken} is {@code null} + */ + private boolean isValidToken(@NotNull final String accessToken) { + return getTokenValidator().validate(accessToken); + } + + @NotNull + private AccessTokenValidator getTokenValidator() { + return accessTokenValidator; + } +} diff --git a/src/main/java/com/qubitpi/ws/jersey/template/web/filters/oauth/AccessTokenValidator.java b/src/main/java/com/qubitpi/ws/jersey/template/web/filters/oauth/AccessTokenValidator.java new file mode 100644 index 00000000..5c7d208d --- /dev/null +++ b/src/main/java/com/qubitpi/ws/jersey/template/web/filters/oauth/AccessTokenValidator.java @@ -0,0 +1,38 @@ +/* + * Copyright Jiaqi Liu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.qubitpi.ws.jersey.template.web.filters.oauth; + +import jakarta.validation.constraints.NotNull; + +/** + * {@link AccessTokenValidator} validates an OAuth 2 access token. + * + * This is a {@link java.util.function functional interface} whose functional method is {@link #validate(String)}. + */ +@FunctionalInterface +public interface AccessTokenValidator { + + /** + * Returns whether or not a specified access token is valid. + * + * @param accessToken The token to validate + * + * @return {@code true} if the token is valid or {@code false} otherwise + * + * @throws NullPointerException if {@code accessToken} is {@code null} + */ + boolean validate(@NotNull String accessToken); +} diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/DataServletITSpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/DataServletITSpec.groovy index d4fd9305..38ea574d 100644 --- a/src/test/groovy/com/qubitpi/ws/jersey/template/DataServletITSpec.groovy +++ b/src/test/groovy/com/qubitpi/ws/jersey/template/DataServletITSpec.groovy @@ -15,12 +15,15 @@ */ package com.qubitpi.ws.jersey.template +import com.qubitpi.ws.jersey.template.web.filters.OAuthFilter + import org.testcontainers.containers.GenericContainer import org.testcontainers.images.PullPolicy import org.testcontainers.images.builder.ImageFromDockerfile import org.testcontainers.spock.Testcontainers import io.restassured.RestAssured +import io.restassured.builder.RequestSpecBuilder import spock.lang.IgnoreIf import spock.lang.Shared import spock.lang.Specification @@ -72,6 +75,11 @@ class DataServletITSpec extends Specification { RestAssured.baseURI = "http://" + container.host RestAssured.port = container.firstMappedPort RestAssured.basePath = "/v1" + RestAssured.requestSpecification = new RequestSpecBuilder() + .addHeader( + OAuthFilter.AUTHORIZATION_HEADER, + OAuthFilter.AUTHORIZATION_SCHEME + " " + "someAccessToken") + .build() } @Unroll diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/JettyServerFactorySpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/JettyServerFactorySpec.groovy index 32bb551b..fc7d2664 100755 --- a/src/test/groovy/com/qubitpi/ws/jersey/template/JettyServerFactorySpec.groovy +++ b/src/test/groovy/com/qubitpi/ws/jersey/template/JettyServerFactorySpec.groovy @@ -15,17 +15,20 @@ */ package com.qubitpi.ws.jersey.template +import com.qubitpi.ws.jersey.template.web.filters.OAuthFilter + import org.eclipse.jetty.server.Server import org.glassfish.jersey.server.ResourceConfig import io.restassured.RestAssured +import io.restassured.builder.RequestSpecBuilder import jakarta.inject.Inject import jakarta.ws.rs.ApplicationPath import spock.lang.Specification class JettyServerFactorySpec extends Specification { - static final int PORT = 8235 + static final int PORT = 8080 static final String ENDPOINT_RESOURCE_PACKAGE = "com.qubitpi.ws.jersey.template.resource" /** @@ -47,6 +50,11 @@ class JettyServerFactorySpec extends Specification { RestAssured.baseURI = "http://localhost" RestAssured.port = PORT RestAssured.basePath = "/v1" + RestAssured.requestSpecification = new RequestSpecBuilder() + .addHeader( + OAuthFilter.AUTHORIZATION_HEADER, + OAuthFilter.AUTHORIZATION_SCHEME + " " + "someAccessToken") + .build() } def "Factory produces Jsersey-Jetty applications"() { diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/web/endpoints/DataServletSpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/web/endpoints/DataServletSpec.groovy index 482aee16..2e0f21f5 100644 --- a/src/test/groovy/com/qubitpi/ws/jersey/template/web/endpoints/DataServletSpec.groovy +++ b/src/test/groovy/com/qubitpi/ws/jersey/template/web/endpoints/DataServletSpec.groovy @@ -17,10 +17,12 @@ package com.qubitpi.ws.jersey.template.web.endpoints import com.qubitpi.ws.jersey.template.JettyServerFactory import com.qubitpi.ws.jersey.template.application.ResourceConfig +import com.qubitpi.ws.jersey.template.web.filters.OAuthFilter import org.eclipse.jetty.server.Server import io.restassured.RestAssured +import io.restassured.builder.RequestSpecBuilder import spock.lang.Specification class DataServletSpec extends Specification { @@ -31,6 +33,11 @@ class DataServletSpec extends Specification { RestAssured.baseURI = "http://localhost" RestAssured.port = PORT RestAssured.basePath = "/v1" + RestAssured.requestSpecification = new RequestSpecBuilder() + .addHeader( + OAuthFilter.AUTHORIZATION_HEADER, + OAuthFilter.AUTHORIZATION_SCHEME + " " + "someAccessToken") + .build() } def "Healthchecking endpoints returns 200"() { diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/web/filters/OAuthFilterSpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/web/filters/OAuthFilterSpec.groovy new file mode 100644 index 00000000..2ab1d686 --- /dev/null +++ b/src/test/groovy/com/qubitpi/ws/jersey/template/web/filters/OAuthFilterSpec.groovy @@ -0,0 +1,85 @@ +/* + * Copyright Jiaqi Liu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.qubitpi.ws.jersey.template.web.filters + +import com.qubitpi.ws.jersey.template.web.filters.oauth.AccessTokenValidator + +import jakarta.ws.rs.container.ContainerRequestContext +import jakarta.ws.rs.core.MultivaluedHashMap +import jakarta.ws.rs.core.MultivaluedMap +import jakarta.ws.rs.core.Response +import spock.lang.Specification +import spock.lang.Unroll + +class OAuthFilterSpec extends Specification { + + def "When request is missing 'Authorization' header, request is aborted"() { + given: "incoming request is missing auth header" + ContainerRequestContext requestContext = Mock(ContainerRequestContext) + requestContext.getHeaders() >> new MultivaluedHashMap() + + when: "filter is trying to validate some token" + new OAuthFilter(Mock(AccessTokenValidator)).filter(requestContext) + + then: "non-existing token header aborts the request" + 1 * requestContext.abortWith(_ as Response) + } + + @Unroll + def "When request comes with #tokenKind access token, request is #abort"() { + given: "a mocked token validator that declares both a valid and invalid token in 2 sequential calls" + AccessTokenValidator validator = Mock(AccessTokenValidator) + validator.validate(_ as String) >> validToken + + and: "request always comes with a access token in header" + MultivaluedMap headers = new MultivaluedHashMap<>() + headers.put(OAuthFilter.AUTHORIZATION_HEADER, [OAuthFilter.AUTHORIZATION_SCHEME + " " + "some_token"]) + + and: "the header is in request context" + ContainerRequestContext requestContext = Mock(ContainerRequestContext) + requestContext.getHeaders() >> headers + + when: "filter validates token with the mocked validator" + new OAuthFilter(validator).filter(requestContext) + + then: "invalid token aborts the request while valid token does not" + (validToken? 0 : 1) * requestContext.abortWith(_ as Response) + + where: + tokenKind | validToken + "valid" | true + "invalid" | false + + abort = validToken ? "not aborted" : "aborted" + } + + @SuppressWarnings('GroovyAccessibility') + def "Filter can extract access token from request header"() { + given: "a header set with a access token in it" + MultivaluedMap headers = new MultivaluedHashMap<>() + headers.put( + OAuthFilter.AUTHORIZATION_HEADER, + [OAuthFilter.AUTHORIZATION_SCHEME + " " + "43rgfgef43ewfg4gergeg43g34g"] + ) + + and: "the header is in request context" + ContainerRequestContext requestContext = Mock(ContainerRequestContext) + requestContext.getHeaders() >> headers + + expect: "filter retrieves the token" + OAuthFilter.getAccessToken(requestContext) == "43rgfgef43ewfg4gergeg43g34g" + } +}