package com.bullhornsdk.data.api.helper; import java.io.IOException; import java.io.InputStream; import java.io.StringWriter; import java.net.URI; import java.net.URLEncoder; import java.util.LinkedHashMap; import java.util.Map; import com.google.common.collect.Maps; import org.apache.commons.httpclient.HttpClient; import org.apache.commons.httpclient.methods.GetMethod; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.apache.log4j.Logger; import org.joda.time.DateTime; import org.joda.time.DateTimeZone; import org.json.JSONException; import org.json.JSONObject; import org.springframework.http.HttpEntity; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import com.bullhornsdk.data.api.BullhornRestCredentials; import com.bullhornsdk.data.exception.RestApiException; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.google.common.base.Splitter; /** * Wraps rest api session management. * * @author Yaniv Or-Shahar * @author Magnus Fiore Palm * */ @JsonIgnoreProperties({"sessionExpired"}) public class RestApiSession { private static final String LOGIN_INFO_URL = "https://rest.bullhornstaffing.com/rest-services/loginInfo?username={username}"; private static final String AUTH_CODE_ACTION = "Login"; private static final String AUTH_CODE_RESPONSE_TYPE = "code"; private static final String ACCESS_TOKEN_GRANT_TYPE = "authorization_code"; private static final String REFRESH_TOKEN_GRANT_TYPE = "refresh_token"; private static Logger log = Logger.getLogger(RestApiSession.class); private RestTemplate restTemplate; private final BullhornRestCredentials restCredentials; private AccessTokenInfo accessTokenInfo; private String bhRestToken; private String restUrl; private String version = "*"; private DateTime dateTimeBhRestTokenWillExpire; private static int SESSION_RETRY = 3; public final static int MAX_TTL = 2880; /** * It is expected that the final members below are not used * (unlike another constructor that takes RestCredentials) */ public RestApiSession() { this.restCredentials = null; } /** * This factory method is used when deserializing from JSON. * It guarantees that "bullhornRestCredentials" property gets assigned first, * ensuring no NullPointerException when other setters are trying to access it, * e.g. setBhRestToken->updateDateTimeBhRestTokenWillExpire */ @JsonCreator public static RestApiSession create(@JsonProperty("bullhornRestCredentials") BullhornRestCredentials bullhornRestCredentials) { return new RestApiSession(bullhornRestCredentials); } public RestApiSession(BullhornRestCredentials bullhornRestCredentials) { this.restCredentials = bullhornRestCredentials; this.restTemplate = RestTemplateFactory.getInstance(); this.dateTimeBhRestTokenWillExpire = getNow(); createSession(); } /** * Returns the BhRestToken to be used when making rest api calls. * * Wraps all session management, such as renewal etc. * * @return * @throws RestApiException */ public String getBhRestToken() throws RestApiException { if (isSessionExpired()) { createSession(); } return bhRestToken; } /** * Refreshes the BhRestToken, expired or not expired, and returns the brand new BhRestToken to be used when making rest api calls. * * Wraps all session management, such as renewal etc. * * @return * @throws RestApiException */ public String refreshBhRestToken() throws RestApiException { createSession(); return bhRestToken; } private void createSession() { for (int tryNumber = 1; tryNumber <= SESSION_RETRY; tryNumber++) { try { String authCode = getAuthorizationCode(); getAccessToken(authCode); login(); break; } catch (Exception e) { if (tryNumber < SESSION_RETRY) { log.error("Error creating REST session. Try number: " + tryNumber + " out of " + SESSION_RETRY + " trying again.", e); } else { log.error("Final error creating REST session. Shutting down.", e); throw new RestApiException("Failed to create rest session", e); } } } } private String getAuthorizationCode() throws RestApiException { String authorizeUrl = getRestAuthorizeUrl(); String clientId = restCredentials.getRestClientId(); String username = getUserName(); String password = getPassword(); String authCode = null; String url = authorizeUrl + "?client_id={clientId}&response_type={responseType}&action={action}&username={username}&password={password}"; Map<String, String> vars = new LinkedHashMap<String, String>(); vars.put("clientId", clientId); vars.put("responseType", AUTH_CODE_RESPONSE_TYPE); vars.put("action", AUTH_CODE_ACTION); vars.put("username", username); vars.put("password", password); try { URI uri = restTemplate.postForLocation(url, null, vars); authCode = getAuthCode(uri); } catch (Exception e) { log.error("Failed to get authorization code.", e); throw new RestApiException("Failed to get authorization code.", e); } return authCode; } /** * restCredentials will only be used in case of multi-tenant app. If not default to username in appSettings. * * @return */ private String getUserName() { return restCredentials.getUsername(); } /** * restCredentials will only be used in case of multi-tenant app. If not default to password in appSettings. * * @return */ private String getPassword() { return restCredentials.getPassword(); } // query: code=###&client_id= private String getAuthCode(URI uri) { String query = uri.getQuery(); Map<String, String> map = Splitter.on("&").trimResults().withKeyValueSeparator('=').split(query); return map.get("code"); } private void getAccessToken(String authCode) throws RestApiException { String tokenUrl = getRestTokenUrl(); String clientId = restCredentials.getRestClientId(); String clientSecret = restCredentials.getRestClientSecret(); String url = tokenUrl + "?grant_type={grantType}&code={authCode}&client_id={clientId}&client_secret={clientSecret}"; Map<String, String> vars = new LinkedHashMap<String, String>(); vars.put("grantType", ACCESS_TOKEN_GRANT_TYPE); vars.put("authCode", authCode); vars.put("clientId", clientId); vars.put("clientSecret", clientSecret); try { accessTokenInfo = restTemplate.postForObject(url, null, AccessTokenInfo.class, vars); } catch (Exception e) { log.error("Failed to get access token.", e); throw new RestApiException("Failed to get access token.", e); } } private void login() { JSONObject responseJson = null; try { String accessTokenString = URLEncoder.encode(accessTokenInfo.getAccessToken(), "UTF-8"); String loginUrl = getRestLoginUrl(); String sessionMinutesToLive = restCredentials.getRestSessionMinutesToLive(); String url = loginUrl + "?version=" + version + "&access_token=" + accessTokenString + "&ttl=" + sessionMinutesToLive; GetMethod get = new GetMethod(url); HttpClient client = new HttpClient(); client.executeMethod(get); String responseStr = streamToString(get.getResponseBodyAsStream()); responseJson = new JSONObject(responseStr); String localBhRestToken = responseJson.getString("BhRestToken"); this.setBhRestToken(localBhRestToken); restUrl = (String) responseJson.get("restUrl"); } catch (RestClientException | IOException e) { log.error("Failed to login. " + responseJson, e); throw new RestApiException("Failed to login and get BhRestToken: " + responseJson, e); } } private String streamToString(InputStream inputStream) throws IOException { StringWriter writer = new StringWriter(); IOUtils.copy(inputStream, writer, "UTF-8"); return writer.toString(); } /** * Check if the DateTime dateTimeBhRestTokenWillExpire in this class is expired. * * Pinging every time will decrease performance. * * @return * @throws RestApiException */ public boolean isSessionExpired() throws RestApiException { boolean sessionExpired = false; if (bhRestTokenExpired()) { // sessionExpired = ping(); sessionExpired = true; } return sessionExpired; } /** * Uses the DateTime in this class to calculate if the session is expired * * @return */ private boolean bhRestTokenExpired() { if (dateTimeBhRestTokenWillExpire.isBeforeNow()) { return true; } return false; } public String getRestUrl() { return restUrl; } private synchronized void setBhRestToken(String bhRestToken) { this.bhRestToken = bhRestToken; updateDateTimeBhRestTokenWillExpire(); } private void updateDateTimeBhRestTokenWillExpire() { // set the DateTime the session will expire, subtracting one minute to be on the safe side. DateTime timeToExpire = getNow(); int sessionMinutesToLive = Integer.valueOf(restCredentials.getRestSessionMinutesToLive()); if (sessionMinutesToLive > MAX_TTL) { sessionMinutesToLive = MAX_TTL; } timeToExpire = timeToExpire.plusMinutes(sessionMinutesToLive - 1); this.dateTimeBhRestTokenWillExpire = timeToExpire; } private DateTime getNow() { return new DateTime(DateTimeZone.forID("EST5EDT")); } public DateTime getDateTimeBhRestTokenWillExpire() { return dateTimeBhRestTokenWillExpire; } public void setDateTimeBhRestTokenWillExpire(DateTime dateTimeBhRestTokenWillExpire) { this.dateTimeBhRestTokenWillExpire = dateTimeBhRestTokenWillExpire; } /** * Will return the un-encrypted RestCredentials for this RestApiSession. Note that this is only needed for a multi-tenant solution * * @return a valid {@link RestCredentials} object if multi-tenant otherwise null */ public BullhornRestCredentials getRestCredentials() { return restCredentials; } private String getRestAuthorizeUrl() { if (StringUtils.isNotBlank(restCredentials.getRestAuthorizeUrl())) { return restCredentials.getRestAuthorizeUrl(); } String restAuthorizeUrl = getBaseOauthUrlFromApi() + "/authorize"; restCredentials.setRestAuthorizeUrl(restAuthorizeUrl); return restCredentials.getRestAuthorizeUrl(); } private String getRestTokenUrl() { if (StringUtils.isNotBlank(restCredentials.getRestTokenUrl())) { return restCredentials.getRestTokenUrl(); } String restTokenUrl = getBaseOauthUrlFromApi() + "/token"; restCredentials.setRestTokenUrl(restTokenUrl); return restCredentials.getRestTokenUrl(); } private String getRestLoginUrl() { if (StringUtils.isNotBlank(restCredentials.getRestLoginUrl())) { return restCredentials.getRestLoginUrl(); } String restLoginUrl = getBaseRestUrlFromApi() + "/login"; restCredentials.setRestLoginUrl(restLoginUrl); return restCredentials.getRestLoginUrl(); } private String baseRestUrl; private synchronized String getBaseRestUrlFromApi() { if (StringUtils.isBlank(this.baseRestUrl)) { JSONObject loginInfo = getLoginInfoFromApi(); String baseRestUrl = loginInfo.getString("restUrl"); if (StringUtils.isBlank(baseRestUrl)) { throw new RestApiException("Failed to dynamically determine REST url with username " + restCredentials.getUsername()); } this.baseRestUrl = baseRestUrl; } return this.baseRestUrl; } private String baseOauthUrl; private synchronized String getBaseOauthUrlFromApi() { if (StringUtils.isBlank(this.baseOauthUrl)) { JSONObject loginInfo = getLoginInfoFromApi(); String baseOauthUrl = loginInfo.getString("oauthUrl"); if (StringUtils.isBlank(baseOauthUrl)) { throw new RestApiException("Failed to dynamically determine oAuth url with username " + restCredentials.getUsername()); } this.baseOauthUrl = baseOauthUrl; } return this.baseOauthUrl; } private JSONObject loginInfo; private synchronized JSONObject getLoginInfoFromApi() { if (loginInfo != null) { return loginInfo; } Map<String, Object> parameters = Maps.newLinkedHashMap(); parameters.put("username", restCredentials.getUsername()); try { ResponseEntity<String> response = restTemplate.exchange(LOGIN_INFO_URL, HttpMethod.GET, HttpEntity.EMPTY, String.class, parameters); if (StringUtils.isBlank(response.getBody())) { throw new RestApiException("Failed to dynamically determine REST urls with username " + restCredentials.getUsername()); } this.loginInfo = new JSONObject(response.getBody()); return this.loginInfo; } catch(RestClientException | JSONException e) { log.error("Error occurred dynamically determining REST urls with username " + restCredentials.getUsername(), e); throw new RestApiException("Failed to dynamically determine REST urls with username " + restCredentials.getUsername()); } } }