From 45c80bbf56d0b1e817c04470b160b24d0e408f2d Mon Sep 17 00:00:00 2001 From: BalloonWen Date: Mon, 11 Feb 2019 17:45:48 -0500 Subject: [PATCH] Feat/#41 exception handling (#380) * Feat/#41 exception handling (#377) merge from fork(most part is done, will keep working on the branch instead of fork.) * -added an error message in status.yml for validating response content -now deserialize content string before do validation -fixed a issue when matching a given uri with schema, now remove query parameters from uri -changed some testing content * -changed status code and moved it to swagger-validator errors position. * -changed OauthHelper.getToken return type to com.networknt.monad.Result so that it can keep Status info when fail -refactored token handler so that it can handle error situation. * -changed Http2Client only get token when success response. and log error when fail. * -added a Jwt model to be used in multiple places to store jwt info. -added more error scenarios when get token from auth server -refactored TokenHandler -added Error Handling for SAMLTokenHandler * -instead of throwing exceptions, all methods that needs to call OauthHelper.populateCCToken have changed return type to "Result" -to avoid modifying class members, move checkCCExpired() method from Http2Client to OauthHelper, and make it public static so that it can be use in multiple modules -TokenHandler in light-router will also use OauthHelper to populate client credential token -added ClientException check in ExceptionHandler -added Status field in ClientException * -changed synchronized(Http2Client.class) to synchronized(OauthHelper.class) since it's moved to OauthHelper now * -created a Enum "ContentType" in http-string -in OauthHelper, whenever needs to pass auth server's response to the client, now it will escape based on the type that the server returns. -related pull request: https://github.com/networknt/light-4j/pull/380 * Removing some unused commits * Fixing bad merge * -fixed duplicate escape response * -added class level comments --- client/pom.xml | 5 +- .../com/networknt/client/Http2Client.java | 161 ++----- .../java/com/networknt/client/oauth/Jwt.java | 105 +++++ .../networknt/client/oauth/OauthHelper.java | 430 +++++++++++++----- .../com/networknt/client/Http2ClientTest.java | 7 +- .../client/oauth/OauthHelperTest.java | 6 +- .../networknt/exception/ExceptionHandler.java | 11 +- .../com/networknt/httpstring/ContentType.java | 34 ++ .../networknt/exception/ClientException.java | 14 + status/src/main/resources/config/status.yml | 21 +- 10 files changed, 548 insertions(+), 246 deletions(-) create mode 100644 client/src/main/java/com/networknt/client/oauth/Jwt.java create mode 100644 http-string/src/main/java/com/networknt/httpstring/ContentType.java diff --git a/client/pom.xml b/client/pom.xml index 7e990880b3..a9399f5176 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -47,6 +47,10 @@ com.networknt status + + com.networknt + monad-result + com.fasterxml.jackson.core jackson-databind @@ -85,5 +89,4 @@ test - diff --git a/client/src/main/java/com/networknt/client/Http2Client.java b/client/src/main/java/com/networknt/client/Http2Client.java index a65c212473..81be700096 100644 --- a/client/src/main/java/com/networknt/client/Http2Client.java +++ b/client/src/main/java/com/networknt/client/Http2Client.java @@ -19,9 +19,6 @@ import java.util.Map; import java.util.ServiceLoader; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.KeyManager; @@ -30,6 +27,8 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; +import io.undertow.client.http.Light4jHttp2ClientProvider; +import io.undertow.client.http.Light4jHttpClientProvider; import org.owasp.encoder.Encode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,19 +44,16 @@ import org.xnio.channels.StreamSinkChannel; import org.xnio.ssl.XnioSsl; -import com.networknt.client.oauth.ClientCredentialsRequest; +import com.networknt.client.oauth.Jwt; import com.networknt.client.oauth.OauthHelper; -import com.networknt.client.oauth.TokenRequest; -import com.networknt.client.oauth.TokenResponse; import com.networknt.client.ssl.ClientX509ExtendedTrustManager; import com.networknt.client.ssl.TLSConfig; import com.networknt.common.DecryptUtil; import com.networknt.common.SecretConstants; import com.networknt.config.Config; -import com.networknt.exception.ApiException; -import com.networknt.exception.ClientException; import com.networknt.httpstring.HttpStringConstants; -import com.networknt.status.Status; +import com.networknt.monad.Failure; +import com.networknt.monad.Result; import com.networknt.utility.ModuleRegistry; import io.undertow.client.ClientCallback; @@ -66,8 +62,6 @@ import io.undertow.client.ClientProvider; import io.undertow.client.ClientRequest; import io.undertow.client.ClientResponse; -import io.undertow.client.http.Light4jHttp2ClientProvider; -import io.undertow.client.http.Light4jHttpClientProvider; import io.undertow.connector.ByteBufferPool; import io.undertow.protocols.ssl.UndertowXnioSsl; import io.undertow.server.DefaultByteBufferPool; @@ -99,7 +93,7 @@ public class Http2Client { public static int bufferSize; public static int DEFAULT_BUFFER_SIZE = 24; // 24*1024 buffer size will be good for most of the app. public static final AttachmentKey RESPONSE_BODY = AttachmentKey.create(String.class); - + static final String TLS = "tls"; static final String BUFFER_SIZE = "bufferSize"; static final String LOAD_TRUST_STORE = "loadTrustStore"; @@ -111,24 +105,15 @@ public class Http2Client { static final String OAUTH = "oauth"; static final String TOKEN = "token"; - static final String TOKEN_RENEW_BEFORE_EXPIRED = "tokenRenewBeforeExpired"; - static final String EXPIRED_REFRESH_RETRY_DELAY = "expiredRefreshRetryDelay"; - static final String EARLY_REFRESH_RETRY_DELAY = "earlyRefreshRetryDelay"; - static final String STATUS_CLIENT_CREDENTIALS_TOKEN_NOT_AVAILABLE = "ERR10009"; + static Map config; static Map tokenConfig; static Map secretConfig; // Cached jwt token for this client. - private String jwt; - private long expire; - private volatile boolean renewing = false; - private volatile long expiredRetryTimeout; - private volatile long earlyRetryTimeout; - - private final Object lock = new Object(); + private final Jwt cachedJwt = new Jwt(); static { List masks = new ArrayList<>(); @@ -192,7 +177,7 @@ private Http2Client(final ClassLoader classLoader) { logger.error("Exception: ", e); } } - + private void addProvider(Map map, String scheme, ClientProvider provider) { if (System.getProperty("java.version").startsWith("1.8.")) {// Java 8 if (Light4jHttpClientProvider.HTTPS.equalsIgnoreCase(scheme)) { @@ -218,11 +203,10 @@ public IoFuture connect(InetSocketAddress bindAddress, final U public IoFuture connect(final URI uri, final XnioWorker worker, XnioSsl ssl, ByteBufferPool bufferPool, OptionMap options) { return connect((InetSocketAddress) null, uri, worker, ssl, bufferPool, options); } + public IoFuture connect(InetSocketAddress bindAddress, final URI uri, final XnioWorker worker, XnioSsl ssl, ByteBufferPool bufferPool, OptionMap options) { ClientProvider provider = getClientProvider(uri); final FutureResult result = new FutureResult<>(); - - provider.connect(new ClientCallback() { @Override public void completed(ClientConnection r) { @@ -233,8 +217,7 @@ public void completed(ClientConnection r) { public void failed(IOException e) { result.setException(e); } - }, bindAddress, uri, worker, ssl, bufferPool, options); - + }, bindAddress, uri, worker, ssl, bufferPool, options); return result.getIoFuture(); } @@ -254,7 +237,6 @@ public IoFuture connect(final URI uri, final XnioIoThread ioTh public IoFuture connect(InetSocketAddress bindAddress, final URI uri, final XnioIoThread ioThread, XnioSsl ssl, ByteBufferPool bufferPool, OptionMap options) { ClientProvider provider = getClientProvider(uri); final FutureResult result = new FutureResult<>(); - provider.connect(new ClientCallback() { @Override public void completed(ClientConnection r) { @@ -265,8 +247,7 @@ public void completed(ClientConnection r) { public void failed(IOException e) { result.setException(e); } - }, bindAddress, uri, ioThread, ssl, bufferPool, options); - + }, bindAddress, uri, ioThread, ssl, bufferPool, options); return result.getIoFuture(); } @@ -369,12 +350,13 @@ public void addAuthTokenTrace(ClientRequest request, String token, String tracea * or mobile apps. * * @param request the http request - * @throws ClientException client exception - * @throws ApiException api exception + * @return Result when fail to get jwt, it will return a Status. */ - public void addCcToken(ClientRequest request) throws ClientException, ApiException { - checkCCTokenExpired(); - request.getRequestHeaders().put(Headers.AUTHORIZATION, "Bearer " + jwt); + public Result addCcToken(ClientRequest request) { + Result result = OauthHelper.populateCCToken(cachedJwt); + if(result.isFailure()) { return Failure.of(result.getError()); } + request.getRequestHeaders().put(Headers.AUTHORIZATION, "Bearer " + result.getResult().getJwt()); + return result; } /** @@ -385,13 +367,14 @@ public void addCcToken(ClientRequest request) throws ClientException, ApiExcepti * * @param request the http request * @param traceabilityId the traceability id - * @throws ClientException client exception - * @throws ApiException api exception + * @return Result when fail to get jwt, it will return a Status. */ - public void addCcTokenTrace(ClientRequest request, String traceabilityId) throws ClientException, ApiException { - checkCCTokenExpired(); - request.getRequestHeaders().put(Headers.AUTHORIZATION, "Bearer " + jwt); + public Result addCcTokenTrace(ClientRequest request, String traceabilityId) { + Result result = OauthHelper.populateCCToken(cachedJwt); + if(result.isFailure()) { return Failure.of(result.getError()); } + request.getRequestHeaders().put(Headers.AUTHORIZATION, "Bearer " + result.getResult().getJwt()); request.getRequestHeaders().put(HttpStringConstants.TRACEABILITY_ID, traceabilityId); + return result; } /** @@ -402,14 +385,12 @@ public void addCcTokenTrace(ClientRequest request, String traceabilityId) throws * * @param request the http request * @param exchange the http server exchange - * @throws ClientException client exception - * @throws ApiException api exception */ - public void propagateHeaders(ClientRequest request, final HttpServerExchange exchange) throws ClientException, ApiException { + public Result propagateHeaders(ClientRequest request, final HttpServerExchange exchange) { String tid = exchange.getRequestHeaders().getFirst(HttpStringConstants.TRACEABILITY_ID); String token = exchange.getRequestHeaders().getFirst(Headers.AUTHORIZATION); String cid = exchange.getRequestHeaders().getFirst(HttpStringConstants.CORRELATION_ID); - populateHeader(request, token, cid, tid); + return populateHeader(request, token, cid, tid); } /** @@ -423,88 +404,22 @@ public void propagateHeaders(ClientRequest request, final HttpServerExchange exc * @param authToken the authorization token * @param correlationId the correlation id * @param traceabilityId the traceability id - * @throws ClientException client exception - * @throws ApiException api exception + * @return Result when fail to get jwt, it will return a Status. */ - public void populateHeader(ClientRequest request, String authToken, String correlationId, String traceabilityId) throws ClientException, ApiException { + public Result populateHeader(ClientRequest request, String authToken, String correlationId, String traceabilityId) { if(traceabilityId != null) { addAuthTokenTrace(request, authToken, traceabilityId); } else { addAuthToken(request, authToken); } + Result result = OauthHelper.populateCCToken(cachedJwt); + if(result.isFailure()) { return Failure.of(result.getError()); } request.getRequestHeaders().put(HttpStringConstants.CORRELATION_ID, correlationId); - checkCCTokenExpired(); - request.getRequestHeaders().put(HttpStringConstants.SCOPE_TOKEN, "Bearer " + jwt); - } - - private void getCCToken() throws ClientException { - TokenRequest tokenRequest = new ClientCredentialsRequest(); - TokenResponse tokenResponse = OauthHelper.getToken(tokenRequest); - synchronized (lock) { - jwt = tokenResponse.getAccessToken(); - // the expiresIn is seconds and it is converted to millisecond in the future. - expire = System.currentTimeMillis() + tokenResponse.getExpiresIn() * 1000; - logger.info("Get client credentials token {} with expire_in {} seconds", jwt, tokenResponse.getExpiresIn()); - } + request.getRequestHeaders().put(HttpStringConstants.SCOPE_TOKEN, "Bearer " + result.getResult().getJwt()); + return result; } - private void checkCCTokenExpired() throws ClientException, ApiException { - long tokenRenewBeforeExpired = (Integer) tokenConfig.get(TOKEN_RENEW_BEFORE_EXPIRED); - long expiredRefreshRetryDelay = (Integer)tokenConfig.get(EXPIRED_REFRESH_RETRY_DELAY); - long earlyRefreshRetryDelay = (Integer)tokenConfig.get(EARLY_REFRESH_RETRY_DELAY); - boolean isInRenewWindow = expire - System.currentTimeMillis() < tokenRenewBeforeExpired; - if(logger.isTraceEnabled()) logger.trace("isInRenewWindow = " + isInRenewWindow); - if(isInRenewWindow) { - if(expire <= System.currentTimeMillis()) { - if(logger.isTraceEnabled()) logger.trace("In renew window and token is expired."); - // block other request here to prevent using expired token. - synchronized (Http2Client.class) { - if(expire <= System.currentTimeMillis()) { - if(logger.isTraceEnabled()) logger.trace("Within the synch block, check if the current request need to renew token"); - if(!renewing || System.currentTimeMillis() > expiredRetryTimeout) { - // if there is no other request is renewing or the renewing flag is true but renewTimeout is passed - renewing = true; - expiredRetryTimeout = System.currentTimeMillis() + expiredRefreshRetryDelay; - if(logger.isTraceEnabled()) logger.trace("Current request is renewing token synchronously as token is expired already"); - getCCToken(); - renewing = false; - } else { - if(logger.isTraceEnabled()) logger.trace("Circuit breaker is tripped and not timeout yet!"); - // reject all waiting requests by thrown an exception. - throw new ApiException(new Status(STATUS_CLIENT_CREDENTIALS_TOKEN_NOT_AVAILABLE)); - } - } - } - } else { - // Not expired yet, try to renew async but let requests use the old token. - if(logger.isTraceEnabled()) logger.trace("In renew window but token is not expired yet."); - synchronized (Http2Client.class) { - if(expire > System.currentTimeMillis()) { - if(!renewing || System.currentTimeMillis() > earlyRetryTimeout) { - renewing = true; - earlyRetryTimeout = System.currentTimeMillis() + earlyRefreshRetryDelay; - if(logger.isTraceEnabled()) logger.trace("Retrieve token async is called while token is not expired yet"); - - ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - - executor.schedule(() -> { - try { - getCCToken(); - renewing = false; - if(logger.isTraceEnabled()) logger.trace("Async get token is completed."); - } catch (Exception e) { - logger.error("Async retrieve token error", e); - // swallow the exception here as it is on a best effort basis. - } - }, 50, TimeUnit.MILLISECONDS); - executor.shutdown(); - } - } - } - } - } - if(logger.isTraceEnabled()) logger.trace("Check secondary token is done!"); - } + private static KeyStore loadKeyStore(final String name, final char[] password) throws IOException { final InputStream stream = Config.getInstance().getInputStreamFromFile(name); @@ -522,10 +437,10 @@ private static KeyStore loadKeyStore(final String name, final char[] password) t IoUtils.safeClose(stream); } } - + /** * default method for creating ssl context. trustedNames config is not used. - * + * * @return SSLContext * @throws IOException */ @@ -535,7 +450,7 @@ public static SSLContext createSSLContext() throws IOException { /** * create ssl context using specified trustedName config - * + * * @param trustedNamesGroupKey - the trustedName config to be used * @return SSLContext * @throws IOException @@ -581,7 +496,7 @@ public static SSLContext createSSLContext(String trustedNamesGroupKey) throws IO if (trustStoreName != null && trustStorePass != null) { KeyStore trustStore = loadKeyStore(trustStoreName, trustStorePass.toCharArray()); TLSConfig tlsConfig = TLSConfig.create(tlsMap, trustedNamesGroupKey); - + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); trustManagerFactory.init(trustStore); trustManagers = ClientX509ExtendedTrustManager.decorate(trustManagerFactory.getTrustManagers(), tlsConfig); @@ -594,7 +509,6 @@ public static SSLContext createSSLContext(String trustedNamesGroupKey) throws IO try { sslContext = SSLContext.getInstance("TLS"); sslContext.init(keyManagers, trustManagers, null); - } catch (NoSuchAlgorithmException | KeyManagementException e) { throw new IOException("Unable to create and initialise the SSLContext", e); } @@ -801,4 +715,5 @@ public void failed(IOException e) { } }; } + } diff --git a/client/src/main/java/com/networknt/client/oauth/Jwt.java b/client/src/main/java/com/networknt/client/oauth/Jwt.java new file mode 100644 index 0000000000..f914aebddc --- /dev/null +++ b/client/src/main/java/com/networknt/client/oauth/Jwt.java @@ -0,0 +1,105 @@ +package com.networknt.client.oauth; + +import com.networknt.config.Config; + +import java.util.Map; + +/** + * a model class represents a JWT mostly for caching usage so that we don't need to decrypt jwt string to get info. + * it will load config from client.yml/oauth/token + */ +public class Jwt { + private String jwt; // the cached jwt token for client credentials grant type + private long expire; // jwt expire time in millisecond so that we don't need to parse the jwt. + private volatile boolean renewing = false; + private volatile long expiredRetryTimeout; + private volatile long earlyRetryTimeout; + + private static long tokenRenewBeforeExpired; + private static long expiredRefreshRetryDelay; + private static long earlyRefreshRetryDelay; + + static final String OAUTH = "oauth"; + static final String TOKEN = "token"; + static final String TOKEN_RENEW_BEFORE_EXPIRED = "tokenRenewBeforeExpired"; + static final String EXPIRED_REFRESH_RETRY_DELAY = "expiredRefreshRetryDelay"; + static final String EARLY_REFRESH_RETRY_DELAY = "earlyRefreshRetryDelay"; + public static final String CLIENT_CONFIG_NAME = "client"; + + public Jwt() { + Map clientConfig = Config.getInstance().getJsonMapConfig(CLIENT_CONFIG_NAME); + if(clientConfig != null) { + Map oauthConfig = (Map)clientConfig.get(OAUTH); + if(oauthConfig != null) { + Map tokenConfig = (Map)oauthConfig.get(TOKEN); + tokenRenewBeforeExpired = (Integer) tokenConfig.get(TOKEN_RENEW_BEFORE_EXPIRED); + expiredRefreshRetryDelay = (Integer)tokenConfig.get(EXPIRED_REFRESH_RETRY_DELAY); + earlyRefreshRetryDelay = (Integer)tokenConfig.get(EARLY_REFRESH_RETRY_DELAY); + } + } + } + + public String getJwt() { + return jwt; + } + + public void setJwt(String jwt) { + this.jwt = jwt; + } + + public long getExpire() { + return expire; + } + + public void setExpire(long expire) { + this.expire = expire; + } + + public boolean isRenewing() { + return renewing; + } + + public void setRenewing(boolean renewing) { + this.renewing = renewing; + } + + public long getExpiredRetryTimeout() { + return expiredRetryTimeout; + } + + public void setExpiredRetryTimeout(long expiredRetryTimeout) { + this.expiredRetryTimeout = expiredRetryTimeout; + } + + public long getEarlyRetryTimeout() { + return earlyRetryTimeout; + } + + public void setEarlyRetryTimeout(long earlyRetryTimeout) { + this.earlyRetryTimeout = earlyRetryTimeout; + } + + public static long getTokenRenewBeforeExpired() { + return tokenRenewBeforeExpired; + } + + public static void setTokenRenewBeforeExpired(long tokenRenewBeforeExpired) { + Jwt.tokenRenewBeforeExpired = tokenRenewBeforeExpired; + } + + public static long getExpiredRefreshRetryDelay() { + return expiredRefreshRetryDelay; + } + + public static void setExpiredRefreshRetryDelay(long expiredRefreshRetryDelay) { + Jwt.expiredRefreshRetryDelay = expiredRefreshRetryDelay; + } + + public static long getEarlyRefreshRetryDelay() { + return earlyRefreshRetryDelay; + } + + public static void setEarlyRefreshRetryDelay(long earlyRefreshRetryDelay) { + Jwt.earlyRefreshRetryDelay = earlyRefreshRetryDelay; + } +} diff --git a/client/src/main/java/com/networknt/client/oauth/OauthHelper.java b/client/src/main/java/com/networknt/client/oauth/OauthHelper.java index fd6ae45262..97a3193a44 100644 --- a/client/src/main/java/com/networknt/client/oauth/OauthHelper.java +++ b/client/src/main/java/com/networknt/client/oauth/OauthHelper.java @@ -1,14 +1,19 @@ package com.networknt.client.oauth; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; import com.networknt.client.Http2Client; import com.networknt.config.Config; import com.networknt.exception.ClientException; +import com.networknt.httpstring.ContentType; +import com.networknt.monad.Failure; +import com.networknt.monad.Result; +import com.networknt.monad.Success; +import com.networknt.status.Status; import io.undertow.UndertowOptions; import io.undertow.client.*; -import io.undertow.util.Headers; -import io.undertow.util.Methods; -import io.undertow.util.StringReadChannelListener; -import io.undertow.util.StringWriteChannelListener; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.*; import org.apache.commons.codec.binary.Base64; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -21,6 +26,8 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -31,94 +38,103 @@ public class OauthHelper { static final String BASIC = "Basic"; static final String GRANT_TYPE = "grant_type"; static final String CODE = "code"; + private static final String FAIL_TO_SEND_REQUEST = "ERR10051"; + private static final String GET_TOKEN_ERROR = "ERR10052"; + private static final String ESTABLISH_CONNECTION_ERROR = "ERR10053"; + private static final String GET_TOKEN_TIMEOUT = "ERR10054"; + public static final String STATUS_CLIENT_CREDENTIALS_TOKEN_NOT_AVAILABLE = "ERR10009"; static final Logger logger = LoggerFactory.getLogger(OauthHelper.class); - public static TokenResponse getToken(TokenRequest tokenRequest) throws ClientException { - final AtomicReference reference = new AtomicReference<>(); + public static Result getToken(TokenRequest tokenRequest) { + final AtomicReference> reference = new AtomicReference<>(); final Http2Client client = Http2Client.getInstance(); final CountDownLatch latch = new CountDownLatch(1); final ClientConnection connection; try { connection = client.connect(new URI(tokenRequest.getServerUrl()), Http2Client.WORKER, Http2Client.SSL, Http2Client.BUFFER_POOL, tokenRequest.enableHttp2 ? OptionMap.create(UndertowOptions.ENABLE_HTTP2, true): OptionMap.EMPTY).get(); } catch (Exception e) { - throw new ClientException(e); + logger.error("cannot establish connection: {}", e.getStackTrace()); + return Failure.of(new Status(ESTABLISH_CONNECTION_ERROR, tokenRequest.getServerUrl())); } try { String requestBody = getEncodedString(tokenRequest); - connection.getIoThread().execute(new Runnable() { - @Override - public void run() { - final ClientRequest request = new ClientRequest().setMethod(Methods.POST).setPath(tokenRequest.getUri()); - request.getRequestHeaders().put(Headers.HOST, "localhost"); - request.getRequestHeaders().put(Headers.TRANSFER_ENCODING, "chunked"); - request.getRequestHeaders().put(Headers.CONTENT_TYPE, "application/x-www-form-urlencoded"); - request.getRequestHeaders().put(Headers.AUTHORIZATION, getBasicAuthHeader(tokenRequest.getClientId(), tokenRequest.getClientSecret())); - connection.sendRequest(request, new ClientCallback() { - @Override - public void completed(ClientExchange result) { - new StringWriteChannelListener(requestBody).setup(result.getRequestChannel()); - result.setResponseListener(new ClientCallback() { - @Override - public void completed(ClientExchange result) { - new StringReadChannelListener(Http2Client.BUFFER_POOL) { - - @Override - protected void stringDone(String string) { - logger.debug("getToken response = " + string); - reference.set(handleResponse(string)); - latch.countDown(); - } - - @Override - protected void error(IOException e) { - logger.error("IOException:", e); - latch.countDown(); - } - }.setup(result.getResponseChannel()); - } - - @Override - public void failed(IOException e) { - logger.error("IOException:", e); - latch.countDown(); - } - }); - } - - @Override - public void failed(IOException e) { - logger.error("IOException:", e); - latch.countDown(); - } - }); - } + connection.getIoThread().execute(() -> { + final ClientRequest request = new ClientRequest().setMethod(Methods.POST).setPath(tokenRequest.getUri()); + request.getRequestHeaders().put(Headers.HOST, "localhost"); + request.getRequestHeaders().put(Headers.TRANSFER_ENCODING, "chunked"); + request.getRequestHeaders().put(Headers.CONTENT_TYPE, "application/x-www-form-urlencoded"); + request.getRequestHeaders().put(Headers.AUTHORIZATION, getBasicAuthHeader(tokenRequest.getClientId(), tokenRequest.getClientSecret())); + connection.sendRequest(request, new ClientCallback() { + @Override + public void completed(ClientExchange result) { + new StringWriteChannelListener(requestBody).setup(result.getRequestChannel()); + result.setResponseListener(new ClientCallback() { + @Override + public void completed(ClientExchange result) { + new StringReadChannelListener(Http2Client.BUFFER_POOL) { + + @Override + protected void stringDone(String string) { + + logger.debug("getToken response = " + string); + reference.set(handleResponse(getContentTypeFromExchange(result), string)); + latch.countDown(); + } + + @Override + protected void error(IOException e) { + logger.error("IOException:", e); + reference.set(Failure.of(new Status(FAIL_TO_SEND_REQUEST))); + latch.countDown(); + } + }.setup(result.getResponseChannel()); + } + + @Override + public void failed(IOException e) { + logger.error("IOException:", e); + reference.set(Failure.of(new Status(FAIL_TO_SEND_REQUEST))); + latch.countDown(); + } + }); + } + + @Override + public void failed(IOException e) { + logger.error("IOException:", e); + reference.set(Failure.of(new Status(FAIL_TO_SEND_REQUEST))); + latch.countDown(); + } + }); }); latch.await(4, TimeUnit.SECONDS); } catch (Exception e) { logger.error("IOException: ", e); - throw new ClientException(e); + return Failure.of(new Status(FAIL_TO_SEND_REQUEST)); } finally { IoUtils.safeClose(connection); } - return reference.get(); + + //if reference.get() is null at this point, mostly likely couldn't get token within latch.await() timeout. + return reference.get() == null ? Failure.of(new Status(GET_TOKEN_TIMEOUT)) : reference.get(); } - public static TokenResponse getTokenFromSaml(SAMLBearerRequest tokenRequest) throws ClientException { - final AtomicReference reference = new AtomicReference<>(); + public static Result getTokenFromSaml(SAMLBearerRequest tokenRequest) { + final AtomicReference> reference = new AtomicReference<>(); final Http2Client client = Http2Client.getInstance(); final CountDownLatch latch = new CountDownLatch(1); final ClientConnection connection; try { connection = client.connect(new URI(tokenRequest.getServerUrl()), Http2Client.WORKER, Http2Client.SSL, Http2Client.BUFFER_POOL, tokenRequest.enableHttp2 ? OptionMap.create(UndertowOptions.ENABLE_HTTP2, true): OptionMap.EMPTY).get(); } catch (Exception e) { - throw new ClientException(e); + logger.error("cannot establish connection: {}", e.getStackTrace()); + return Failure.of(new Status(ESTABLISH_CONNECTION_ERROR)); } try { - Map postBody = new HashMap(); postBody.put(SAMLBearerRequest.GRANT_TYPE_KEY , SAMLBearerRequest.GRANT_TYPE_VALUE ); postBody.put(SAMLBearerRequest.ASSERTION_KEY, tokenRequest.getSamlAssertion()); @@ -127,67 +143,65 @@ public static TokenResponse getTokenFromSaml(SAMLBearerRequest tokenRequest) thr String requestBody = Http2Client.getFormDataString(postBody); logger.debug(requestBody); - connection.getIoThread().execute(new Runnable() { - - @Override - public void run() { - final ClientRequest request = new ClientRequest().setMethod(Methods.POST).setPath(tokenRequest.getUri()); - request.getRequestHeaders().put(Headers.HOST, "localhost"); - request.getRequestHeaders().put(Headers.TRANSFER_ENCODING, "chunked"); - request.getRequestHeaders().put(Headers.CONTENT_TYPE, "application/x-www-form-urlencoded"); - - - - connection.sendRequest(request, new ClientCallback() { - - @Override - public void completed(ClientExchange result) { - new StringWriteChannelListener(requestBody).setup(result.getRequestChannel()); - result.setResponseListener(new ClientCallback() { - @Override - public void completed(ClientExchange result) { - new StringReadChannelListener(Http2Client.BUFFER_POOL) { - - @Override - protected void stringDone(String string) { - logger.debug("getToken response = " + string); - reference.set(handleResponse(string)); - latch.countDown(); - } - - @Override - protected void error(IOException e) { - logger.error("IOException:", e); - latch.countDown(); - } - }.setup(result.getResponseChannel()); - } - - @Override - public void failed(IOException e) { - logger.error("IOException:", e); - latch.countDown(); - } - }); - } - - @Override - public void failed(IOException e) { - logger.error("IOException:", e); - latch.countDown(); - } - }); - } + connection.getIoThread().execute(() -> { + final ClientRequest request = new ClientRequest().setMethod(Methods.POST).setPath(tokenRequest.getUri()); + request.getRequestHeaders().put(Headers.HOST, "localhost"); + request.getRequestHeaders().put(Headers.TRANSFER_ENCODING, "chunked"); + request.getRequestHeaders().put(Headers.CONTENT_TYPE, "application/x-www-form-urlencoded"); + + connection.sendRequest(request, new ClientCallback() { + + @Override + public void completed(ClientExchange result) { + new StringWriteChannelListener(requestBody).setup(result.getRequestChannel()); + result.setResponseListener(new ClientCallback() { + @Override + public void completed(ClientExchange result) { + new StringReadChannelListener(Http2Client.BUFFER_POOL) { + + @Override + protected void stringDone(String string) { + logger.debug("getToken response = " + string); + reference.set(handleResponse(getContentTypeFromExchange(result), string)); + latch.countDown(); + } + + @Override + protected void error(IOException e) { + logger.error("IOException:", e); + reference.set(Failure.of(new Status(FAIL_TO_SEND_REQUEST))); + latch.countDown(); + } + }.setup(result.getResponseChannel()); + } + + @Override + public void failed(IOException e) { + logger.error("IOException:", e); + reference.set(Failure.of(new Status(FAIL_TO_SEND_REQUEST))); + latch.countDown(); + } + }); + } + + @Override + public void failed(IOException e) { + logger.error("IOException:", e); + reference.set(Failure.of(new Status(FAIL_TO_SEND_REQUEST))); + latch.countDown(); + } + }); }); latch.await(4, TimeUnit.SECONDS); } catch (Exception e) { logger.error("IOException: ", e); - throw new ClientException(e); + return Failure.of(new Status(FAIL_TO_SEND_REQUEST)); } finally { IoUtils.safeClose(connection); } - return reference.get(); + //if reference.get() is null at this point, mostly likely couldn't get token within latch.await() timeout. + return reference.get() == null ? Failure.of(new Status(GET_TOKEN_TIMEOUT)) : reference.get(); } public static String getKey(KeyRequest keyRequest) throws ClientException { @@ -284,18 +298,202 @@ private static String getEncodedString(TokenRequest request) throws UnsupportedE return Http2Client.getFormDataString(params); } - private static TokenResponse handleResponse(String responseBody) { - TokenResponse tokenResponse = null; + private static Result handleResponse(ContentType contentType, String responseBody) { + TokenResponse tokenResponse; + Result result; try { + //only accept json format response so that can map to a TokenResponse, otherwise escapes server's response and return to the client. + if(!contentType.equals(ContentType.APPLICATION_JSON)) { + return Failure.of(new Status(GET_TOKEN_ERROR, escapeBasedOnType(contentType, responseBody))); + } if (responseBody != null && responseBody.length() > 0) { tokenResponse = Config.getInstance().getMapper().readValue(responseBody, TokenResponse.class); + if(tokenResponse != null) { + result = Success.of(tokenResponse); + } else { + result = Failure.of(new Status(GET_TOKEN_ERROR, responseBody)); + } } else { + result = Failure.of(new Status(GET_TOKEN_ERROR, "no auth server response")); logger.error("Error in token retrieval, response = " + responseBody); } + } catch (UnrecognizedPropertyException e) { + //in this case, cannot parse success token, which means the server doesn't response a successful token but some messages, we need to pass this message out. + result = Failure.of(new Status(GET_TOKEN_ERROR, escapeBasedOnType(contentType, responseBody))); } catch (IOException | RuntimeException e) { + result = Failure.of(new Status(GET_TOKEN_ERROR, e.getMessage())); logger.error("Error in token retrieval", e); } - return tokenResponse; + return result; + } + + public static void sendStatusToResponse(HttpServerExchange exchange, Status status) { + exchange.setStatusCode(status.getStatusCode()); + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/json"); + exchange.getResponseSender().send(status.toString()); + StackTraceElement[] elements = Thread.currentThread().getStackTrace(); + logger.error(status.toString() + " at " + elements[2].getClassName() + "." + elements[2].getMethodName() + "(" + elements[2].getFileName() + ":" + elements[2].getLineNumber() + ")"); + } + + /** + * populate/renew jwt info to the give jwt object. + * based on the expire time of the jwt, to determine if need to renew jwt or not. + * to avoid modifying class member which will case thread-safe problem, move this method from Http2Client to this helper class. + * @param jwt the given jwt needs to renew or populate + * @return When success return Jwt; When fail return Status. + */ + public static Result populateCCToken(Jwt jwt) { + boolean isInRenewWindow = jwt.getExpire() - System.currentTimeMillis() < jwt.getTokenRenewBeforeExpired(); + logger.trace("isInRenewWindow = " + isInRenewWindow); + //if not in renew window, return the current jwt. + if(!isInRenewWindow) { return Success.of(jwt); } + //block other getting token requests, only once at a time. + //Once one request get the token, other requests don't need to get from auth server anymore. + synchronized (OauthHelper.class) { + //if token expired, try to renew synchronously + if(jwt.getExpire() <= System.currentTimeMillis()) { + Result result = renewCCTokenSync(jwt); + if(logger.isTraceEnabled()) logger.trace("Check secondary token is done!"); + return result; + } else { + //otherwise renew token silently + renewCCTokenAsync(jwt); + if(logger.isTraceEnabled()) logger.trace("Check secondary token is done!"); + return Success.of(jwt); + } + } + } + + /** + * renew Client Credential token synchronously. + * When success will renew the Jwt jwt passed in. + * When fail will return Status code so that can be handled by caller. + * @param jwt the jwt you want to renew + * @return Jwt when success, it will be the same object as the jwt you passed in; return Status when fail; + */ + private static Result renewCCTokenSync(final Jwt jwt) { + // Already expired, try to renew getCCTokenSynchronously but let requests use the old token. + logger.trace("In renew window and token is already expired."); + //the token can be renew when it's not on renewing or current time is lager than retrying interval + if (!jwt.isRenewing() || System.currentTimeMillis() > jwt.getExpiredRetryTimeout()) { + jwt.setRenewing(true); + jwt.setEarlyRetryTimeout(System.currentTimeMillis() + jwt.getExpiredRefreshRetryDelay()); + Result result = getCCTokenRemotely(jwt); + //set renewing flag to false no mater fail or success + jwt.setRenewing(false); + return result; + } else { + if(logger.isTraceEnabled()) logger.trace("Circuit breaker is tripped and not timeout yet!"); + // token is renewing + return Failure.of(new Status(STATUS_CLIENT_CREDENTIALS_TOKEN_NOT_AVAILABLE)); + } + } + + /** + * renew the given Jwt jwt asynchronously. + * When fail, it will swallow the exception, so no need return type to be handled by caller. + * @param jwt the jwt you want to renew + */ + private static void renewCCTokenAsync(Jwt jwt) { + // Not expired yet, try to renew async but let requests use the old token. + logger.trace("In renew window but token is not expired yet."); + if(!jwt.isRenewing() || System.currentTimeMillis() > jwt.getEarlyRetryTimeout()) { + jwt.setRenewing(true); + jwt.setEarlyRetryTimeout(System.currentTimeMillis() + jwt.getEarlyRefreshRetryDelay()); + logger.trace("Retrieve token async is called while token is not expired yet"); + + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + + executor.schedule(() -> { + Result result = getCCTokenRemotely(jwt); + if(result.isFailure()) { + // swallow the exception here as it is on a best effort basis. + logger.error("Async retrieve token error with status: {}", result.getError().toString()); + } + //set renewing flag to false after response, doesn't matter if it's success or fail. + jwt.setRenewing(false); + }, 50, TimeUnit.MILLISECONDS); + executor.shutdown(); + } + } + + /** + * get Client Credential token from auth server + * @param jwt the jwt you want to renew + * @return Jwt when success, it will be the same object as the jwt you passed in; return Status when fail; + */ + private static Result getCCTokenRemotely(final Jwt jwt) { + TokenRequest tokenRequest = new ClientCredentialsRequest(); + Result result = OauthHelper.getToken(tokenRequest); + if(result.isSuccess()) { + TokenResponse tokenResponse = result.getResult(); + jwt.setJwt(tokenResponse.getAccessToken()); + // the expiresIn is seconds and it is converted to millisecond in the future. + jwt.setExpire(System.currentTimeMillis() + tokenResponse.getExpiresIn() * 1000); + logger.info("Get client credentials token {} with expire_in {} seconds", jwt, tokenResponse.getExpiresIn()); + return Success.of(jwt); + } else { + logger.info("Get client credentials token fail with status: {}", result.getError().toString()); + return Failure.of(result.getError()); + } } + public static ContentType getContentTypeFromExchange(ClientExchange exchange) { + HeaderValues headerValues = exchange.getResponse().getResponseHeaders().get(Headers.CONTENT_TYPE); + return headerValues == null ? ContentType.ANY_TYPE : ContentType.toContentType(headerValues.getFirst()); + } + + private static String escapeBasedOnType(ContentType contentType, String responseBody) { + switch (contentType) { + case APPLICATION_JSON: + try { + String escapedStr = Config.getInstance().getMapper().writeValueAsString(responseBody); + return escapedStr.substring(1,escapedStr.length()-1); + } catch (JsonProcessingException e) { + logger.error("escape json response fails"); + return responseBody; + } + case XML: + //very rare case because the server should response a json format response + return escapeXml(responseBody); + default: + return responseBody; + } + } + + /** + * Instead of including a large library just for escaping xml, using this util. + * it should be used in very rare cases because the server should not return xml format message + * @param nonEscapedXmlStr + */ + private static String escapeXml (String nonEscapedXmlStr) { + StringBuilder escapedXML = new StringBuilder(); + for (int i = 0; i < nonEscapedXmlStr.length(); i++) { + char c = nonEscapedXmlStr.charAt(i); + switch (c) { + case '<': + escapedXML.append("<"); + break; + case '>': + escapedXML.append(">"); + break; + case '\"': + escapedXML.append("""); + break; + case '&': + escapedXML.append("&"); + break; + case '\'': + escapedXML.append("'"); + break; + default: + if (c > 0x7e) { + escapedXML.append("&#" + ((int) c) + ";"); + } else { + escapedXML.append(c); + } + } + } + return escapedXML.toString(); + } } diff --git a/client/src/test/java/com/networknt/client/Http2ClientTest.java b/client/src/test/java/com/networknt/client/Http2ClientTest.java index b6124de47d..10ce27c66b 100644 --- a/client/src/test/java/com/networknt/client/Http2ClientTest.java +++ b/client/src/test/java/com/networknt/client/Http2ClientTest.java @@ -1,6 +1,7 @@ package com.networknt.client; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import java.io.IOException; import java.io.InputStream; @@ -792,7 +793,7 @@ public void server_identity_check_negative_case() throws Exception{ client.connect(new URI("https://localhost:7778"), worker, ssl, Http2Client.BUFFER_POOL, OptionMap.create(UndertowOptions.ENABLE_HTTP2, true)).get(); //should not be reached - assertTrue(false); + fail(); } @Test(expected=ClosedChannelException.class) @@ -804,7 +805,7 @@ public void standard_https_hostname_check_kicks_in_if_trustednames_are_empty() t client.connect(new URI("https://127.0.0.1:7778"), worker, ssl, Http2Client.BUFFER_POOL, OptionMap.create(UndertowOptions.ENABLE_HTTP2, true)).get(); //should not be reached - assertTrue(false); + fail(); } @Test(expected=ClosedChannelException.class) @@ -816,7 +817,7 @@ public void standard_https_hostname_check_kicks_in_if_trustednames_are_not_used_ client.connect(new URI("https://127.0.0.1:7778"), worker, ssl, Http2Client.BUFFER_POOL, OptionMap.create(UndertowOptions.ENABLE_HTTP2, true)).get(); //should not be reached - assertTrue(false); + fail(); } @Test diff --git a/client/src/test/java/com/networknt/client/oauth/OauthHelperTest.java b/client/src/test/java/com/networknt/client/oauth/OauthHelperTest.java index 37b9419aa3..211d3df70a 100644 --- a/client/src/test/java/com/networknt/client/oauth/OauthHelperTest.java +++ b/client/src/test/java/com/networknt/client/oauth/OauthHelperTest.java @@ -2,6 +2,7 @@ import com.networknt.client.Http2Client; import com.networknt.config.Config; +import com.networknt.monad.Result; import io.undertow.Handlers; import io.undertow.Undertow; import io.undertow.util.Headers; @@ -14,6 +15,7 @@ import org.jose4j.jwt.consumer.JwtConsumerBuilder; import org.jose4j.lang.JoseException; import org.junit.AfterClass; +import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Test; import org.slf4j.Logger; @@ -207,7 +209,9 @@ public void testGetToken() throws Exception { tokenRequest.setRedirectUri("https://localhost:8443/authorize"); tokenRequest.setAuthCode("test_code"); - TokenResponse tokenResponse = OauthHelper.getToken(tokenRequest); + Result result = OauthHelper.getToken(tokenRequest); + Assert.assertTrue(result.isSuccess()); + TokenResponse tokenResponse = result.getResult(); System.out.println("tokenResponse = " + tokenResponse); } diff --git a/exception/src/main/java/com/networknt/exception/ExceptionHandler.java b/exception/src/main/java/com/networknt/exception/ExceptionHandler.java index 3d001470f2..5dd80c15b1 100644 --- a/exception/src/main/java/com/networknt/exception/ExceptionHandler.java +++ b/exception/src/main/java/com/networknt/exception/ExceptionHandler.java @@ -19,7 +19,6 @@ import com.networknt.config.Config; import com.networknt.handler.Handler; import com.networknt.handler.MiddlewareHandler; -import com.networknt.status.Status; import com.networknt.utility.ModuleRegistry; import io.undertow.Handlers; import io.undertow.server.HttpHandler; @@ -94,6 +93,16 @@ public void handleRequest(final HttpServerExchange exchange) throws Exception { exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/json"); exchange.getResponseSender().send(ae.getStatus().toString()); logger.error(ae.getStatus().toString(), e); + } else if(e instanceof ClientException){ + ClientException ce = (ClientException)e; + if(ce.getStatus().getStatusCode() == 0){ + setExchangeStatus(exchange, STATUS_UNCAUGHT_EXCEPTION); + } else { + exchange.setStatusCode(ce.getStatus().getStatusCode()); + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/json"); + exchange.getResponseSender().send(ce.getStatus().toString()); + } + } else { setExchangeStatus(exchange, STATUS_UNCAUGHT_EXCEPTION); } diff --git a/http-string/src/main/java/com/networknt/httpstring/ContentType.java b/http-string/src/main/java/com/networknt/httpstring/ContentType.java new file mode 100644 index 0000000000..57e1038ec1 --- /dev/null +++ b/http-string/src/main/java/com/networknt/httpstring/ContentType.java @@ -0,0 +1,34 @@ +package com.networknt.httpstring; + +/** + * a enum for http Content-Type header + * currently only support 3 types JSON, XML AND *\/* + */ +public enum ContentType { + APPLICATION_JSON("application/json"), + XML("text/xml"), + ANY_TYPE("*/*"); + + private String value; + + ContentType(String contentType) { + this.value = contentType; + } + + public String value() { + return this.value; + } + + /** + * @param value content type str eg: application/json + * @return ContentType + */ + public static ContentType toContentType(String value) { + for(ContentType v : values()){ + if(value.toUpperCase().contains(v.value().toUpperCase())) { + return v; + } + } + return ANY_TYPE; + } +} diff --git a/status/src/main/java/com/networknt/exception/ClientException.java b/status/src/main/java/com/networknt/exception/ClientException.java index 104efb2769..2f44eab00b 100644 --- a/status/src/main/java/com/networknt/exception/ClientException.java +++ b/status/src/main/java/com/networknt/exception/ClientException.java @@ -16,6 +16,8 @@ package com.networknt.exception; +import com.networknt.status.Status; + /** * This is a checked exception used by Client module. * @@ -23,6 +25,7 @@ */ public class ClientException extends Exception { private static final long serialVersionUID = 1L; + private static Status status = new Status(); public ClientException() { super(); @@ -32,6 +35,10 @@ public ClientException(String message) { super(message); } + public ClientException(Status status) { + this.status = status; + } + public ClientException(String message, Throwable cause) { super(message, cause); } @@ -40,4 +47,11 @@ public ClientException(Throwable cause) { super(cause); } + public static Status getStatus() { + return status; + } + + public static void setStatus(Status status) { + ClientException.status = status; + } } diff --git a/status/src/main/resources/config/status.yml b/status/src/main/resources/config/status.yml index c40b8ba46b..e06a3c97cb 100644 --- a/status/src/main/resources/config/status.yml +++ b/status/src/main/resources/config/status.yml @@ -377,7 +377,26 @@ ERR10050: code: ERR10050 message: NO_ENCODING_HANDLER description: No encoding handler for the required encoder. - +ERR10051: + statusCode: 401 + code: ERR10051 + message: FAIL_TO_SEND_REQUEST + description: Fail to send request to target server. +ERR10052: + statusCode: 401 + code: ERR10052 + message: GET_TOKEN_ERROR + description: "Cannot get valid token: %s." +ERR10053: + statusCode: 401 + code: ERR10053 + message: ESTABLISH_CONNECTION_ERROR + description: Cannot establish connection for url %s. +ERR10054: + statusCode: 401 + code: ERR10054 + message: GET_TOKEN_TIMEOUT + description: Cannot get valid token, probably due to a timeout. # 11000-11500 swagger-validator errors ERR11000: