diff --git a/jwt-opa/build.gradle b/jwt-opa/build.gradle index 4104a97..2d046d4 100644 --- a/jwt-opa/build.gradle +++ b/jwt-opa/build.gradle @@ -38,7 +38,7 @@ ext { } group 'com.alertavert' -version '0.10.0' +version '0.11.0' // OpenJDK 17 LTS is the only Java version supported sourceCompatibility = JavaVersion.VERSION_17 diff --git a/jwt-opa/src/main/java/com/alertavert/opa/Constants.java b/jwt-opa/src/main/java/com/alertavert/opa/Constants.java index a3ea215..545d626 100644 --- a/jwt-opa/src/main/java/com/alertavert/opa/Constants.java +++ b/jwt-opa/src/main/java/com/alertavert/opa/Constants.java @@ -117,7 +117,6 @@ public Collection getAuthorities() { @Override public boolean isCredentialsNonExpired() {return false;} @Override public boolean isEnabled() {return false;} }; - public static final int MAX_TOKEN_LEN_LOG = 6; public static final ObjectMapper MAPPER = new ObjectMapper(); public static final String PEM_EXT = ".pem"; public static final String PUB_EXT = ".pub"; diff --git a/jwt-opa/src/main/java/com/alertavert/opa/jwt/ApiTokenAuthenticationFactory.java b/jwt-opa/src/main/java/com/alertavert/opa/jwt/ApiTokenAuthenticationFactory.java index b40e77c..3ddbcea 100644 --- a/jwt-opa/src/main/java/com/alertavert/opa/jwt/ApiTokenAuthenticationFactory.java +++ b/jwt-opa/src/main/java/com/alertavert/opa/jwt/ApiTokenAuthenticationFactory.java @@ -21,7 +21,6 @@ import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.interfaces.DecodedJWT; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -31,8 +30,6 @@ import java.util.List; -import static com.alertavert.opa.Constants.MAX_TOKEN_LEN_LOG; - /** *

ApiTokenAuthenticationFactory

* @@ -62,7 +59,7 @@ public ApiTokenAuthenticationFactory(JwtTokenProvider provider) { * grant with the {@link JwtTokenProvider#ROLES} carried by the JWT. */ public Mono createAuthentication(String token) { - log.debug("Authenticating token {}...", token.substring(0, Math.min(MAX_TOKEN_LEN_LOG, token.length()))); + log.debug("Authenticating token {}", JwtTokenProvider.maskToken(token)); try { DecodedJWT jwt = provider.decode(token); List authorities = AuthorityUtils.createAuthorityList( diff --git a/jwt-opa/src/main/java/com/alertavert/opa/jwt/JwtTokenProvider.java b/jwt-opa/src/main/java/com/alertavert/opa/jwt/JwtTokenProvider.java index 3be90f3..b64a457 100644 --- a/jwt-opa/src/main/java/com/alertavert/opa/jwt/JwtTokenProvider.java +++ b/jwt-opa/src/main/java/com/alertavert/opa/jwt/JwtTokenProvider.java @@ -26,7 +26,6 @@ import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.jwt.interfaces.JWTVerifier; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.lang.Nullable; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -51,14 +50,38 @@ @Component @Slf4j public class JwtTokenProvider { - public static final String ROLES = "roles"; + private static final String MASK = "****"; + private static final int MASKED_TOKEN_LEN = 12; + + private final Algorithm hmac; + private final TokensProperties tokensProperties; - @Autowired - Algorithm hmac; + public JwtTokenProvider(Algorithm hmac, TokensProperties tokensProperties) { + this.hmac = hmac; + this.tokensProperties = tokensProperties; + } - @Autowired - TokensProperties tokensProperties; + /** + * This method takes an API Token and masks it by replacing the middle part with {@link #MASK}, + * and the first and last characters of the token, so that the total length is + * {@link #MASKED_TOKEN_LEN}. + * + * @param token the API Token to mask + * @return a masked version of the token + */ + public static String maskToken(String token) { + if (token == null) { + return ""; + } + var totLen = Math.min(token.length(), MASKED_TOKEN_LEN); + if (totLen <= MASK.length()) { + return MASK; + } + int prefixLen = Math.max((totLen - MASK.length()) / 2, 1); + return token.substring(0, prefixLen) + MASK + + token.substring(token.length() - prefixLen); + } public JWTVerifier verifier() { return JWT.require(hmac) diff --git a/jwt-opa/src/main/java/com/alertavert/opa/security/OpaReactiveAuthorizationManager.java b/jwt-opa/src/main/java/com/alertavert/opa/security/OpaReactiveAuthorizationManager.java index b09ad22..6e5730c 100644 --- a/jwt-opa/src/main/java/com/alertavert/opa/security/OpaReactiveAuthorizationManager.java +++ b/jwt-opa/src/main/java/com/alertavert/opa/security/OpaReactiveAuthorizationManager.java @@ -74,7 +74,7 @@ public class OpaReactiveAuthorizationManager @PostConstruct private void info() { - log.info("Configured Headers, headers = {}", requiredHeaders); + log.info("Configured headers = {}", requiredHeaders); } /** @@ -142,22 +142,20 @@ private TokenBasedAuthorizationRequest makeRequestBody( ) { Map authnHeaders = new HashMap<>(); HttpHeaders requestHeaders = request.getHeaders(); - log.debug("Adding headers, request = {}, required = {}", requestHeaders, + log.debug("Adding headers, request = {}, required = {}", requestHeaders.keySet(), requiredHeaders); - if (requestHeaders != null) { - requiredHeaders.forEach(key -> { - var value = requestHeaders.getFirst(key); - if (value != null) { - authnHeaders.put(key, value); - } - }); - } + requiredHeaders.forEach(key -> { + var value = requestHeaders.getFirst(key); + if (value != null) { + authnHeaders.put(key, value); + } + }); String token = Objects.requireNonNull(credentials).toString(); return TokenBasedAuthorizationRequest.builder() .input(new TokenBasedAuthorizationRequest.AuthRequestBody(token, new TokenBasedAuthorizationRequest.Resource( - request.getMethodValue(), + request.getMethod().name(), request.getPath().toString(), authnHeaders ) @@ -165,14 +163,4 @@ private TokenBasedAuthorizationRequest makeRequestBody( ) .build(); } - - private WebClientResponseException unauthorized() { - return WebClientResponseException.create( - HttpStatus.UNAUTHORIZED.value(), - HttpStatus.UNAUTHORIZED.getReasonPhrase(), - null, - USER_NOT_AUTHORIZED.getBytes(StandardCharsets.UTF_8), - StandardCharsets.UTF_8 - ); - } } diff --git a/jwt-opa/src/main/java/com/alertavert/opa/security/TokenBasedAuthorizationRequest.java b/jwt-opa/src/main/java/com/alertavert/opa/security/TokenBasedAuthorizationRequest.java index 4541905..f5f623c 100644 --- a/jwt-opa/src/main/java/com/alertavert/opa/security/TokenBasedAuthorizationRequest.java +++ b/jwt-opa/src/main/java/com/alertavert/opa/security/TokenBasedAuthorizationRequest.java @@ -18,16 +18,24 @@ package com.alertavert.opa.security; +import com.alertavert.opa.jwt.JwtTokenProvider; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.FormatFeature; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.PrettyPrinter; +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; import lombok.Builder; import lombok.Value; import lombok.extern.jackson.Jacksonized; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import java.util.Formattable; +import java.util.Formatter; import java.util.Map; +import java.util.stream.Collectors; import static com.alertavert.opa.Constants.MAPPER; -import static com.alertavert.opa.Constants.MAX_TOKEN_LEN_LOG; /** @@ -40,17 +48,17 @@ * "input"} object, we use this class to simplify the construction of the JSON body. * // {{ formatter:off }} -
-      {
-        "input": {
-            "api_token": ".... API Token Base-64 encoded ...",
-            "resource": {
-                "method": "POST",
-                "path": "/path/to/resource"
-           }
-        }
-      }
-    
+
+ {
+ "input": {
+ "api_token": ".... API Token Base-64 encoded ...",
+ "resource": {
+ "method": "POST",
+ "path": "/path/to/resource"
+ }
+ }
+ }
+ 
// {{ formatter:on }} * *

When serializing to String (e.g., in debug logs output) the API Token (JWT) is obfuscated @@ -62,30 +70,43 @@ @Value @Builder @Jacksonized +@Slf4j public class TokenBasedAuthorizationRequest { public record Resource(String method, String path, Map headers) { } - public record AuthRequestBody(@JsonProperty("api_token") String token, Resource resource) { + public record AuthRequestBody(@JsonProperty("api_token") String token, + Resource resource) implements Formattable { + + private static final PrettyPrinter PRETTY_PRINTER = new DefaultPrettyPrinter() + .withoutSpacesInObjectEntries(); + + @Override + public void formatTo(Formatter formatter, int flags, int width, int precision) { + // Creates a new Map with all the headers, excluding the Authorization one. + Map maskedHeaders = resource.headers().entrySet().stream() + .filter(e -> !e.getKey().equalsIgnoreCase(HttpHeaders.AUTHORIZATION)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + try { + AuthRequestBody copy = new AuthRequestBody(JwtTokenProvider.maskToken(token), + new Resource(resource.method(), resource.path(), maskedHeaders)); + formatter.format("%s", MAPPER.writer().with(PRETTY_PRINTER) + .writeValueAsString(copy)); + } catch (JsonProcessingException e) { + formatter.format("invalid JSON: %s", e.getMessage()); + } + } } AuthRequestBody input; @Override public String toString() { - try { - String token = ""; - if (input.token.length() > 2 * MAX_TOKEN_LEN_LOG) { - token = input.token.substring(0, MAX_TOKEN_LEN_LOG) + "****" + - input.token.substring(input.token.length() - MAX_TOKEN_LEN_LOG); - } - TokenBasedAuthorizationRequest copy = TokenBasedAuthorizationRequest.builder() - .input(new AuthRequestBody(token, input.resource)) - .build(); - return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(copy); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } + return String.format(""" + { + "input": %s + } + """, input); } } diff --git a/jwt-opa/src/test/java/com/alertavert/opa/security/OpaReactiveAuthorizationManagerTest.java b/jwt-opa/src/test/java/com/alertavert/opa/security/OpaReactiveAuthorizationManagerTest.java index 3deb69d..6b4fa9d 100644 --- a/jwt-opa/src/test/java/com/alertavert/opa/security/OpaReactiveAuthorizationManagerTest.java +++ b/jwt-opa/src/test/java/com/alertavert/opa/security/OpaReactiveAuthorizationManagerTest.java @@ -106,7 +106,7 @@ void check() { } @Test - public void checkUnauthorizedFails() { + void checkUnauthorizedFails() { Authentication auth = factory.createAuthentication( provider.createToken("alice", Lists.list("USER")) ).block(); @@ -121,7 +121,7 @@ public void checkUnauthorizedFails() { } @Test - public void checkUnauthenticatedFails() { + void checkUnauthenticatedFails() { Authentication auth = new UsernamePasswordAuthenticationToken("bob", "pass"); // As this endpoint is not mapped in `routes` (application-test.yaml) it expects by default @@ -142,7 +142,7 @@ private AuthorizationContext getAuthorizationContextWithHeaders( ServerHttpRequest request = mock(ServerHttpRequest.class); RequestPath requestPath = mock(RequestPath.class); - when(request.getMethodValue()).thenReturn(method.name()); + when(request.getMethod()).thenReturn(method); when(request.getPath()).thenReturn(requestPath); when(requestPath.toString()).thenReturn(path); @@ -159,7 +159,7 @@ private AuthorizationContext getAuthorizationContextWithHeaders( } @Test - public void authenticatedEndpointBypassesOpa() { + void authenticatedEndpointBypassesOpa() { AuthorizationContext context = getAuthorizationContext(HttpMethod.GET, "/testauth"); assertThat(opaReactiveAuthorizationManager.check( factory.createAuthentication( @@ -170,7 +170,7 @@ public void authenticatedEndpointBypassesOpa() { } @Test - public void authenticatedEndpointMatches() { + void authenticatedEndpointMatches() { // In the test configuration (application-test.yaml) we have configured the following // path matchers: ["/match/*/this", "/match/any/**"]. // Here we test that an authenticated user gains access to them without needing authorization. @@ -202,7 +202,7 @@ public void authenticatedEndpointMatches() { } @Test - public void testHeaders() { + void testHeaders() { AuthorizationContext context = getAuthorizationContextWithHeaders(HttpMethod.GET, "/whatever", Map.of("x-test-header", "test-value", HttpHeaders.USER_AGENT, "TestAgent")); assertThat(opaReactiveAuthorizationManager.check( diff --git a/jwt-opa/src/test/java/com/alertavert/opa/security/TokenBasedAuthorizationRequestTest.java b/jwt-opa/src/test/java/com/alertavert/opa/security/TokenBasedAuthorizationRequestTest.java index 48b1a37..d0100eb 100644 --- a/jwt-opa/src/test/java/com/alertavert/opa/security/TokenBasedAuthorizationRequestTest.java +++ b/jwt-opa/src/test/java/com/alertavert/opa/security/TokenBasedAuthorizationRequestTest.java @@ -26,7 +26,9 @@ import static com.alertavert.opa.Constants.MAPPER; import static com.jayway.jsonpath.matchers.JsonPathMatchers.hasJsonPath; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertFalse; class TokenBasedAuthorizationRequestTest { @@ -48,15 +50,18 @@ void serialize() throws Exception { @Test void obfuscatesJwt() { TokenBasedAuthorizationRequest request = TokenBasedAuthorizationRequest.builder() - .input(new AuthRequestBody("tokenAAjwtDEF123456.anothertoken.yetanothertoken", + .input(new AuthRequestBody("AAjwtDEF123456.ZZZfere43535.yYYYY98764awarkfajser", new TokenBasedAuthorizationRequest.Resource("POST", "/foo/bar", Map.of())) ) .build(); - String json = request.toString(); - - assertThat(json, hasJsonPath("$.input")); - assertThat(json, hasJsonPath("$.input.api_token", equalTo("tokenA****rtoken"))); - assertThat(json, hasJsonPath("$.input.resource.method", equalTo("POST"))); - assertThat(json, hasJsonPath("$.input.resource.path", equalTo("/foo/bar"))); + assertThat(request.toString(), containsString("AAjw****jser")); + assertFalse(request.toString().matches(".*AAjwtDEF123456\\.ZZZfere43535\\" + + ".yYYYY98764awarkfajser.*")); + assertThat(request.toString(), containsString(""" + "method":"POST", + """)); + assertThat(request.toString(), containsString(""" + "path":"/foo/bar", + """)); } } diff --git a/webapp-example/build.gradle b/webapp-example/build.gradle index d79db09..c13259a 100644 --- a/webapp-example/build.gradle +++ b/webapp-example/build.gradle @@ -51,8 +51,8 @@ dependencies { // as to emulate an actual project using jwt-opa externally. // Uncomment the following line (and comment out the one below) to use the local version // while developing. - // implementation project (':jwt-opa') - implementation "com.alertavert:jwt-opa:${jwtOpaVersion}" + implementation project (':jwt-opa') +// implementation "com.alertavert:jwt-opa:${jwtOpaVersion}" // For the @PostConstruct annotation implementation 'javax.annotation:javax.annotation-api:1.3.2' diff --git a/webapp-example/src/main/java/com/alertavert/opademo/api/JwtController.java b/webapp-example/src/main/java/com/alertavert/opademo/api/JwtController.java index 6200f7e..680691e 100644 --- a/webapp-example/src/main/java/com/alertavert/opademo/api/JwtController.java +++ b/webapp-example/src/main/java/com/alertavert/opademo/api/JwtController.java @@ -19,15 +19,9 @@ package com.alertavert.opademo.api; import com.alertavert.opa.jwt.JwtTokenProvider; -import com.alertavert.opa.security.crypto.KeypairReader; import com.alertavert.opademo.data.ReactiveUsersRepository; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.extern.jackson.Jacksonized; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.util.MimeTypeUtils; import org.springframework.web.bind.annotation.GetMapping; @@ -36,13 +30,11 @@ import org.springframework.web.bind.annotation.RestController; import reactor.core.publisher.Mono; -import java.security.KeyPair; import java.util.List; import java.util.Objects; import static com.alertavert.opa.Constants.API_TOKEN; import static com.alertavert.opa.Constants.BEARER_TOKEN; -import static com.alertavert.opa.Constants.MAX_TOKEN_LEN_LOG; @Slf4j @RestController @@ -69,8 +61,8 @@ public Mono> getToken(@PathVariable String user) { }) .map(ResponseEntity::ok) .doOnSuccess(response -> log.debug( - "API Token successfully created, user = {}, token = {}...", user, - Objects.requireNonNull(response.getBody()).apiToken.substring(0, MAX_TOKEN_LEN_LOG))) + "API Token successfully created, user = {}, token = {}", user, + JwtTokenProvider.maskToken(Objects.requireNonNull(response.getBody()).apiToken))) .onErrorReturn(Exception.class, ResponseEntity.badRequest().build()); } diff --git a/webapp-example/src/main/java/com/alertavert/opademo/api/LoginController.java b/webapp-example/src/main/java/com/alertavert/opademo/api/LoginController.java index cb09e5b..8a4611b 100644 --- a/webapp-example/src/main/java/com/alertavert/opademo/api/LoginController.java +++ b/webapp-example/src/main/java/com/alertavert/opademo/api/LoginController.java @@ -38,7 +38,6 @@ import java.util.UUID; import static com.alertavert.opa.Constants.BASIC_AUTH; -import static com.alertavert.opa.Constants.MAX_TOKEN_LEN_LOG; /** *

LoginController

@@ -77,8 +76,9 @@ Mono login( return new JwtController.ApiToken(u.getUsername(), u.roles(), token); }) .doOnNext(apiToken -> - log.debug("User authenticated, user = {}, token = {}...", - apiToken.username(), apiToken.apiToken().substring(0, MAX_TOKEN_LEN_LOG))); + log.debug("User authenticated, user = {}, token = {}", + apiToken.username(), + JwtTokenProvider.maskToken(apiToken.apiToken()))); } @GetMapping("/reset/{username}") diff --git a/webapp-example/src/main/resources/application.yaml b/webapp-example/src/main/resources/application.yaml index 3150964..d65f3d2 100644 --- a/webapp-example/src/main/resources/application.yaml +++ b/webapp-example/src/main/resources/application.yaml @@ -30,7 +30,7 @@ opa: # # NOTE: this is a quirk of Spring Boot: configure this with a comma-separated list # NOT a YAML array, as that will NOT work as expected. - headers: x-demoapp-auth, Accept-Encoding + headers: x-demoapp-auth, Accept-Encoding, Authorization db: server: localhost