diff --git a/pom.xml b/pom.xml index 2e86d53bf6..0fcd999259 100644 --- a/pom.xml +++ b/pom.xml @@ -315,6 +315,18 @@ 1.10.19 test + + com.auth0 + java-jwt + 4.4.0 + + + com.fasterxml.jackson.core + jackson-databind + + + + diff --git a/src/main/java/com/twilio/TwilioOauth.java b/src/main/java/com/twilio/TwilioOauth.java new file mode 100644 index 0000000000..b6cf2fb446 --- /dev/null +++ b/src/main/java/com/twilio/TwilioOauth.java @@ -0,0 +1,77 @@ +package com.twilio; + +import com.twilio.exception.AuthenticationException; +import com.twilio.http.TwilioOAuthRestClient; +import lombok.Getter; + +import java.util.List; + +public class TwilioOauth { + public static final String JAVA_VERSION = System.getProperty("java.version"); + public static final String OS_NAME = System.getProperty("os.name"); + public static final String OS_ARCH = System.getProperty("os.arch"); + private static String clientId = System.getenv("TWILIO_CLIENT_ID"); + private static String clientSecret = System.getenv("TWILIO_CLIENT_SECRET"); + @Getter + private static List userAgentExtensions; + private static String region = System.getenv("TWILIO_REGION"); + private static String edge = System.getenv("TWILIO_EDGE"); + + private static volatile TwilioOAuthRestClient oAuthRestClient; + + private TwilioOauth() { + } + public static synchronized void init(final String clientId, final String clientSecret) { + TwilioOauth.setClientId(clientId); + TwilioOauth.setClientSecret(clientSecret); + } + + public static synchronized void setClientId(final String clientId) { + if (clientId == null) { + throw new AuthenticationException("Client Id can not be null"); + } + + TwilioOauth.clientId = clientId; + } + + public static synchronized void setClientSecret(final String clientSecret) { + if (clientSecret == null) { + throw new AuthenticationException("Client Secret can not be null"); + } + + TwilioOauth.clientSecret = clientSecret; + } + + public static TwilioOAuthRestClient getRestClient() { + if (TwilioOauth.oAuthRestClient == null) { + synchronized (TwilioOauth.class) { + if (TwilioOauth.oAuthRestClient == null) { + TwilioOauth.oAuthRestClient = buildOAuthRestClient(); + } + } + } + + return TwilioOauth.oAuthRestClient; + } + + private static TwilioOAuthRestClient buildOAuthRestClient() { + if (TwilioOauth.clientId == null || TwilioOauth.clientSecret == null) { + throw new AuthenticationException( + "TwilioOAuthRestClient was used before ClientId and ClientSecret were set, please call TwilioOauth.init()" + ); + } + + TwilioOAuthRestClient.Builder builder = new TwilioOAuthRestClient.Builder(TwilioOauth.clientId, TwilioOauth.clientSecret); + + if (userAgentExtensions != null) { + builder.userAgentExtensions(TwilioOauth.userAgentExtensions); + } + + builder.region(TwilioOauth.region); + builder.edge(TwilioOauth.edge); + + return builder.build(); + } + + +} diff --git a/src/main/java/com/twilio/http/BearerTokenRequest.java b/src/main/java/com/twilio/http/BearerTokenRequest.java new file mode 100644 index 0000000000..5302975af8 --- /dev/null +++ b/src/main/java/com/twilio/http/BearerTokenRequest.java @@ -0,0 +1,48 @@ +package com.twilio.http; + +public class BearerTokenRequest extends Request { + + protected static final String DEFAULT_REGION = "us1"; + public static final String QUERY_STRING_DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"; + public static final String QUERY_STRING_DATE_FORMAT = "yyyy-MM-dd"; + + private String bearerToken; + + /** + * Create a new API request. + * + * @param method HTTP method + * @param url url of request + */ + public BearerTokenRequest(final HttpMethod method, final String url) { + super(method, url); + } + + /** + * Create a new API request. + * + * @param method HTTP method + * @param domain Twilio domain + * @param uri uri of request + */ + public BearerTokenRequest(final HttpMethod method, final String domain, final String uri) { + this(method, domain, uri, null); + } + + /** + * Create a new API request. + * + * @param method HTTP Method + * @param domain Twilio domain + * @param uri uri of request + * @param region region to make request + */ + public BearerTokenRequest( + final HttpMethod method, + final String domain, + final String uri, + final String region + ) { + super(method, domain, uri, region); + } +} diff --git a/src/main/java/com/twilio/http/OAuthHttpClient.java b/src/main/java/com/twilio/http/OAuthHttpClient.java new file mode 100644 index 0000000000..a6e1426dc1 --- /dev/null +++ b/src/main/java/com/twilio/http/OAuthHttpClient.java @@ -0,0 +1,113 @@ +package com.twilio.http; + +import com.twilio.Twilio; +import com.twilio.constant.EnumConstants; +import com.twilio.exception.ApiException; +import org.apache.http.HttpEntity; +import org.apache.http.HttpHeaders; +import org.apache.http.HttpResponse; +import org.apache.http.HttpVersion; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.RequestBuilder; +import org.apache.http.client.utils.HttpClientUtils; +import org.apache.http.config.SocketConfig; +import org.apache.http.entity.BufferedHttpEntity; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.message.BasicHeader; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +public class OAuthHttpClient extends HttpClient { + protected final org.apache.http.client.HttpClient client; + + public OAuthHttpClient() { + this(DEFAULT_REQUEST_CONFIG); + } + + public OAuthHttpClient(final RequestConfig requestConfig) { + this(requestConfig, DEFAULT_SOCKET_CONFIG); + } + + public OAuthHttpClient(final RequestConfig requestConfig, final SocketConfig socketConfig) { + Collection headers = Arrays.asList( + new BasicHeader("X-Twilio-Client", "java-" + Twilio.VERSION), + new BasicHeader(HttpHeaders.ACCEPT, "application/json"), + new BasicHeader(HttpHeaders.ACCEPT_ENCODING, "utf-8") + ); + + String googleAppEngineVersion = System.getProperty("com.google.appengine.runtime.version"); + boolean isGoogleAppEngine = googleAppEngineVersion != null && !googleAppEngineVersion.isEmpty(); + + org.apache.http.impl.client.HttpClientBuilder clientBuilder = HttpClientBuilder.create(); + + if (!isGoogleAppEngine) { + clientBuilder.useSystemProperties(); + } + + PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); + connectionManager.setDefaultSocketConfig(socketConfig); + /* + * Example: Lets say client has one server. + * There are 4 servers on edge handling client request. + * Each request takes on an average 500ms (2 request per second) + * Total number request can be server in a second from a route: 20 * 4 * 2 (DefaultMaxPerRoute * edge servers * request per second) + */ + connectionManager.setDefaultMaxPerRoute(5); + connectionManager.setMaxTotal(20); + + client = clientBuilder + .setConnectionManager(connectionManager) + .setDefaultRequestConfig(requestConfig) + .setDefaultHeaders(headers) + .setRedirectStrategy(this.getRedirectStrategy()) + .build(); + } + + @Override + public Response makeRequest(Request request) { + HttpMethod method = request.getMethod(); + RequestBuilder builder = RequestBuilder.create(method.toString()) + .setUri(request.constructURL().toString()) + .setVersion(HttpVersion.HTTP_1_1) + .setCharset(StandardCharsets.UTF_8); + + + for (Map.Entry> entry : request.getHeaderParams().entrySet()) { + for (String value : entry.getValue()) { + builder.addHeader(entry.getKey(), value); + } + } + HttpEntity requestEntity = new StringEntity(request.getBody(), ContentType.APPLICATION_JSON); + builder.setEntity(requestEntity); + builder.addHeader( + HttpHeaders.CONTENT_TYPE, EnumConstants.ContentType.JSON.getValue()); + builder.addHeader(HttpHeaders.USER_AGENT, HttpUtility.getUserAgentString(request.getUserAgentExtensions())); + HttpResponse response = null; + + try { + response = client.execute(builder.build()); + HttpEntity entity = response.getEntity(); + return new Response( + // Consume the entire HTTP response before returning the stream + entity == null ? null : new BufferedHttpEntity(entity).getContent(), + response.getStatusLine().getStatusCode(), + response.getAllHeaders() + ); + } catch (IOException e) { + throw new ApiException(e.getMessage(), e); + } finally { + + // Ensure this response is properly closed + HttpClientUtils.closeQuietly(response); + + } + } +} diff --git a/src/main/java/com/twilio/http/OAuthRequest.java b/src/main/java/com/twilio/http/OAuthRequest.java new file mode 100644 index 0000000000..f17f231221 --- /dev/null +++ b/src/main/java/com/twilio/http/OAuthRequest.java @@ -0,0 +1,49 @@ +package com.twilio.http; + +public class OAuthRequest extends Request { + + protected static final String DEFAULT_REGION = "us1"; + public static final String QUERY_STRING_DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"; + public static final String QUERY_STRING_DATE_FORMAT = "yyyy-MM-dd"; + + private String clientId; + private String clientSecret; + + /** + * Create a new API request. + * + * @param method HTTP method + * @param url url of request + */ + public OAuthRequest(final HttpMethod method, final String url) { + super(method, url); + } + + /** + * Create a new API request. + * + * @param method HTTP method + * @param domain Twilio domain + * @param uri uri of request + */ + public OAuthRequest(final HttpMethod method, final String domain, final String uri) { + this(method, domain, uri, null); + } + + /** + * Create a new API request. + * + * @param method HTTP Method + * @param domain Twilio domain + * @param uri uri of request + * @param region region to make request + */ + public OAuthRequest( + final HttpMethod method, + final String domain, + final String uri, + final String region + ) { + super(method, domain, uri, region); + } +} diff --git a/src/main/java/com/twilio/http/OAuthTokenManager.java b/src/main/java/com/twilio/http/OAuthTokenManager.java new file mode 100644 index 0000000000..f26c698dbd --- /dev/null +++ b/src/main/java/com/twilio/http/OAuthTokenManager.java @@ -0,0 +1,89 @@ +package com.twilio.http; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.twilio.TwilioOauth; +import com.twilio.exception.ApiConnectionException; +import com.twilio.exception.ApiException; +import com.twilio.exception.RestException; +import com.twilio.models.OAuthRequestBody; +import com.twilio.models.OAuthResponseBody; +import com.twilio.rest.content.v1.Content; + +import java.io.IOException; +import java.io.InputStream; + +public class OAuthTokenManager { + private static OAuthTokenManager instance; + private OAuthRequestBody oAuthRequestBody; + private OAuthResponseBody oAuthResponseBody; + + private TwilioOAuthRestClient twilioOAuthRestClient = TwilioOauth.getRestClient(); + + public static OAuthTokenManager getInstance() { + if (instance == null) { + synchronized (OAuthTokenManager.class) { + if (instance == null) { + instance = new OAuthTokenManager(); + } + } + } + return instance; + } + + private OAuthTokenManager() { } + + public String fetchToken(OAuthRequest request) { + if (oAuthResponseBody != null && !oAuthResponseBody.isAccessTokenExpired()) { + System.out.println("Using existing token"); + return oAuthResponseBody.getAccessToken(); + } + this.oAuthResponseBody = null; + System.out.println("Fetching token from server"); + Response response = twilioOAuthRestClient.request(request); + handleResponseExceptions(response); + + this.oAuthResponseBody = fromJson(response.getStream(), twilioOAuthRestClient.getObjectMapper()); + if (oAuthResponseBody.getAccessToken() == null || oAuthResponseBody.getAccessToken().isEmpty()) { + throw new ApiException( + "OAuth access token is missing or invalid." + ); + } + return this.oAuthResponseBody.getAccessToken(); + } + + private void handleResponseExceptions(Response response) { + if (response == null) { + throw new ApiConnectionException( + "Content creation failed: Unable to connect to server" + ); + } else if (!TwilioOAuthRestClient.SUCCESS.test(response.getStatusCode())) { + RestException restException = RestException.fromJson( + response.getStream(), + twilioOAuthRestClient.getObjectMapper() + ); + if (restException == null) { + throw new ApiException( + "Server Error, no content", + response.getStatusCode() + ); + } + throw new ApiException(restException); + } + } + + private OAuthResponseBody fromJson( + final InputStream json, + final ObjectMapper objectMapper + ) { + // Convert all checked exceptions to Runtime + try { + return objectMapper.readValue(json, OAuthResponseBody.class); + } catch (final JsonMappingException | JsonParseException e) { + throw new ApiException(e.getMessage(), e); + } catch (final IOException e) { + throw new ApiConnectionException(e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/twilio/http/Request.java b/src/main/java/com/twilio/http/Request.java index 36aa7a5e03..39caabfa02 100644 --- a/src/main/java/com/twilio/http/Request.java +++ b/src/main/java/com/twilio/http/Request.java @@ -25,22 +25,22 @@ public class Request { public static final String QUERY_STRING_DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"; public static final String QUERY_STRING_DATE_FORMAT = "yyyy-MM-dd"; - private final HttpMethod method; - private final String url; - private final Map> queryParams; - private final Map> postParams; - private final Map> headerParams; - - private String region; - private String edge; + protected final HttpMethod method; + protected final String url; + protected final Map> queryParams; + protected final Map> postParams; + protected final Map> headerParams; + + protected String region; + protected String edge; private String username; private String password; - private List userAgentExtensions; + protected List userAgentExtensions; - private EnumConstants.ContentType contentType; + protected EnumConstants.ContentType contentType; - private String body; + protected String body; /** * Create a new API request. diff --git a/src/main/java/com/twilio/http/TwilioOAuthRestClient.java b/src/main/java/com/twilio/http/TwilioOAuthRestClient.java new file mode 100644 index 0000000000..6c388fd5bd --- /dev/null +++ b/src/main/java/com/twilio/http/TwilioOAuthRestClient.java @@ -0,0 +1,162 @@ +package com.twilio.http; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.twilio.exception.ApiConnectionException; +import com.twilio.exception.ApiException; +import com.twilio.exception.RestException; +import com.twilio.models.OAuthRequestBody; +import com.twilio.rest.content.v1.Content; +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +public class TwilioOAuthRestClient { + public static final int HTTP_STATUS_CODE_CREATED = 201; + public static final int HTTP_STATUS_CODE_NO_CONTENT = 204; + public static final int HTTP_STATUS_CODE_OK = 200; + public static final Predicate SUCCESS = i -> i != null && i >= 200 && i < 400; + @Getter + private final ObjectMapper objectMapper; + @Getter + private final String region; + @Getter + private final String edge; + + private OAuthRequestBody oAuthRequestBody; + + private static final Logger logger = LoggerFactory.getLogger(TwilioOAuthRestClient.class); + + @Getter + private final HttpClient httpClient; + @Getter + private final List userAgentExtensions; + protected TwilioOAuthRestClient(Builder builder) { + this.oAuthRequestBody = builder.oAuthModel; + this.region = builder.region; + this.edge = builder.edge; + this.httpClient = builder.httpClient; + this.objectMapper = new ObjectMapper(); + this.userAgentExtensions = builder.userAgentExtensions; + } + public static class Builder { + private OAuthRequestBody oAuthModel; + private String region; + private String edge; + private HttpClient httpClient; + private List userAgentExtensions; + + public Builder(final String clientId, final String clientSecret) { + this.oAuthModel = new OAuthRequestBody(clientId, clientSecret); + } + + public TwilioOAuthRestClient.Builder region(final String region) { + this.region = region; + return this; + } + + public TwilioOAuthRestClient.Builder edge(final String edge) { + this.edge = edge; + return this; + } + + public TwilioOAuthRestClient.Builder httpClient(final HttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + + public TwilioOAuthRestClient.Builder userAgentExtensions(final List userAgentExtensions) { + if (userAgentExtensions != null && !userAgentExtensions.isEmpty()) { + this.userAgentExtensions = new ArrayList<>(userAgentExtensions); + } + return this; + } + + public TwilioOAuthRestClient build() { + if (this.httpClient == null) { + this.httpClient = new OAuthHttpClient(); + } + return new TwilioOAuthRestClient(this); + } + } + + public Response request(final OAuthRequest request) { + + buildRequest(request); + logRequest(request); + Response response = httpClient.reliableRequest(request); + + if (logger.isDebugEnabled()) { + logger.debug("status code: {}", response.getStatusCode()); + org.apache.http.Header[] responseHeaders = response.getHeaders(); + logger.debug("response headers:"); + for (int i = 0; i < responseHeaders.length; i++) { + logger.debug("responseHeader: {}", responseHeaders[i]); + } + } + return response; + } + + private void buildRequest(OAuthRequest request) { + request.setBody(Content.toJson(oAuthRequestBody, objectMapper)); + if (region != null) + request.setRegion(region); + if (edge != null) + request.setEdge(edge); + + if (userAgentExtensions != null && !userAgentExtensions.isEmpty()) { + request.setUserAgentExtensions(userAgentExtensions); + } + } + + private void handleOAuthResponse(Response response) { + if (response == null) { + throw new ApiConnectionException( + "Content creation failed: Unable to connect to server" + ); + } else if (!TwilioRestClient.SUCCESS.test(response.getStatusCode())) { + RestException restException = RestException.fromJson( + response.getStream(), + objectMapper + ); + if (restException == null) { + throw new ApiException( + "Server Error, no content", + response.getStatusCode() + ); + } + throw new ApiException(restException); + } + } + + + + public void logRequest(final Request request) { + if (logger.isDebugEnabled()) { + logger.debug("-- BEGIN Twilio API Request --"); + logger.debug("request method: " + request.getMethod()); + logger.debug("request URL: " + request.constructURL().toString()); + final Map> queryParams = request.getQueryParams(); + final Map> headerParams = request.getHeaderParams(); + + if (queryParams != null && !queryParams.isEmpty()) { + logger.debug("query parameters: " + queryParams); + } + + if (headerParams != null && !headerParams.isEmpty()) { + logger.debug("header parameters: "); + for (String key : headerParams.keySet()) { + if (!key.toLowerCase().contains("authorization")) { + logger.debug(key + ": " + headerParams.get(key)); + } + } + } + + logger.debug("-- END Twilio API Request --"); + } + } +} diff --git a/src/main/java/com/twilio/models/OAuthRequestBody.java b/src/main/java/com/twilio/models/OAuthRequestBody.java new file mode 100644 index 0000000000..8034262a5c --- /dev/null +++ b/src/main/java/com/twilio/models/OAuthRequestBody.java @@ -0,0 +1,38 @@ +package com.twilio.models; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@ToString +public class OAuthRequestBody { + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @JsonProperty("client_id") + @Setter + private String clientId; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @JsonProperty("client_secret") + @Setter + private String clientSecret; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @JsonProperty("audience") + @Getter + @Setter + private String audience = "https://www.twilio.com/organizations"; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @JsonProperty("grant_type") + @Getter + @Setter + private String grantType = "client_credentials"; + + public OAuthRequestBody(String clientId, String clientSecret) { + this.clientId = clientId; + this.clientSecret = clientSecret; + } +} diff --git a/src/main/java/com/twilio/models/OAuthResponseBody.java b/src/main/java/com/twilio/models/OAuthResponseBody.java new file mode 100644 index 0000000000..c07e0f280e --- /dev/null +++ b/src/main/java/com/twilio/models/OAuthResponseBody.java @@ -0,0 +1,38 @@ +package com.twilio.models; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.util.Date; + +@ToString +public class OAuthResponseBody { + + @JsonProperty("access_token") + @Getter + @Setter + private String accessToken; + + @JsonProperty("expires_in") + @Getter + @Setter + private String expiresIn; + + @JsonProperty("token_type") + @Getter + @Setter + private String tokenType; + + public boolean isAccessTokenExpired() { + DecodedJWT jwt = JWT.decode(this.accessToken); + Date expiresAt = jwt.getExpiresAt(); + // Add a buffer of 30 seconds + long bufferMilliseconds = 30 * 1000; + Date bufferExpiresAt = new Date(expiresAt.getTime() - bufferMilliseconds); + return bufferExpiresAt.before(new Date()); + } +} \ No newline at end of file