001/* 002 * Copyright (c) 2010-2024 Mark Allen, Norbert Bartels. 003 * 004 * Permission is hereby granted, free of charge, to any person obtaining a copy 005 * of this software and associated documentation files (the "Software"), to deal 006 * in the Software without restriction, including without limitation the rights 007 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 008 * copies of the Software, and to permit persons to whom the Software is 009 * furnished to do so, subject to the following conditions: 010 * 011 * The above copyright notice and this permission notice shall be included in 012 * all copies or substantial portions of the Software. 013 * 014 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 015 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 016 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 017 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 018 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 019 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 020 * THE SOFTWARE. 021 */ 022package com.restfb; 023 024import static com.restfb.logging.RestFBLogger.CLIENT_LOGGER; 025import static com.restfb.util.EncodingUtils.decodeBase64; 026import static com.restfb.util.ObjectUtil.requireNotEmpty; 027import static com.restfb.util.ObjectUtil.verifyParameterPresence; 028import static com.restfb.util.StringUtils.*; 029import static com.restfb.util.UrlUtils.urlEncode; 030import static java.lang.String.format; 031import static java.net.HttpURLConnection.*; 032import static java.util.Arrays.asList; 033import static java.util.Collections.emptyList; 034 035import java.io.IOException; 036import java.util.*; 037import java.util.stream.Collectors; 038import java.util.stream.Stream; 039 040import javax.crypto.Mac; 041import javax.crypto.spec.SecretKeySpec; 042 043import com.restfb.WebRequestor.Response; 044import com.restfb.batch.BatchRequest; 045import com.restfb.batch.BatchResponse; 046import com.restfb.exception.*; 047import com.restfb.exception.devicetoken.*; 048import com.restfb.exception.generator.DefaultFacebookExceptionGenerator; 049import com.restfb.exception.generator.FacebookExceptionGenerator; 050import com.restfb.json.*; 051import com.restfb.scope.ScopeBuilder; 052import com.restfb.types.DebugTokenInfo; 053import com.restfb.types.DeviceCode; 054import com.restfb.util.EncodingUtils; 055import com.restfb.util.ObjectUtil; 056import com.restfb.util.StringUtils; 057 058/** 059 * Default implementation of a <a href="http://developers.facebook.com/docs/api">Facebook Graph API</a> client. 060 * 061 * @author <a href="http://restfb.com">Mark Allen</a> 062 */ 063public class DefaultFacebookClient extends BaseFacebookClient implements FacebookClient { 064 public static final String CLIENT_ID = "client_id"; 065 public static final String APP_ID = "appId"; 066 public static final String APP_SECRET = "appSecret"; 067 public static final String SCOPE = "scope"; 068 public static final String CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE = "Unable to extract access token from response."; 069 public static final String PARAM_CLIENT_SECRET = "client_secret"; 070 071 public static final String CONNECTION = "connection"; 072 public static final String CONNECTION_TYPE = "connectionType"; 073 public static final String ALGORITHM = "algorithm"; 074 /** 075 * Graph API access token. 076 */ 077 protected String accessToken; 078 079 /** 080 * Graph API app secret. 081 */ 082 private String appSecret; 083 084 /** 085 * facebook exception generator to convert Facebook error json into java exceptions 086 */ 087 private FacebookExceptionGenerator graphFacebookExceptionGenerator; 088 089 /** 090 * holds the Facebook endpoint urls 091 */ 092 private FacebookEndpoints facebookEndpointUrls = new FacebookEndpoints() {}; 093 094 /** 095 * Reserved "multiple IDs" parameter name. 096 */ 097 protected static final String IDS_PARAM_NAME = "ids"; 098 099 /** 100 * Version of API endpoint. 101 */ 102 protected Version apiVersion; 103 104 /** 105 * By default this is <code>false</code>, so real http DELETE is used 106 */ 107 protected boolean httpDeleteFallback; 108 109 protected boolean accessTokenInHeader; 110 111 protected DefaultFacebookClient() { 112 this(Version.LATEST); 113 } 114 115 /** 116 * Creates a Facebook Graph API client with the given {@code apiVersion}. 117 * 118 * @param apiVersion 119 * Version of the api endpoint 120 */ 121 public DefaultFacebookClient(Version apiVersion) { 122 this(null, null, new DefaultWebRequestor(), new DefaultJsonMapper(), apiVersion); 123 } 124 125 /** 126 * Creates a Facebook Graph API client with the given {@code accessToken}. 127 * 128 * @param accessToken 129 * A Facebook OAuth access token. 130 * @param apiVersion 131 * Version of the api endpoint 132 * @since 1.6.14 133 */ 134 public DefaultFacebookClient(String accessToken, Version apiVersion) { 135 this(accessToken, null, new DefaultWebRequestor(), new DefaultJsonMapper(), apiVersion); 136 } 137 138 /** 139 * Creates a Facebook Graph API client with the given {@code accessToken}. 140 * 141 * @param accessToken 142 * A Facebook OAuth access token. 143 * @param appSecret 144 * A Facebook application secret. 145 * @param apiVersion 146 * Version of the api endpoint 147 * @since 1.6.14 148 */ 149 public DefaultFacebookClient(String accessToken, String appSecret, Version apiVersion) { 150 this(accessToken, appSecret, new DefaultWebRequestor(), new DefaultJsonMapper(), apiVersion); 151 } 152 153 /** 154 * Creates a Facebook Graph API client with the given {@code accessToken}. 155 * 156 * @param accessToken 157 * A Facebook OAuth access token. 158 * @param webRequestor 159 * The {@link WebRequestor} implementation to use for sending requests to the API endpoint. 160 * @param jsonMapper 161 * The {@link JsonMapper} implementation to use for mapping API response JSON to Java objects. 162 * @param apiVersion 163 * Version of the api endpoint 164 * @throws NullPointerException 165 * If {@code jsonMapper} or {@code webRequestor} is {@code null}. 166 * @since 1.6.14 167 */ 168 public DefaultFacebookClient(String accessToken, WebRequestor webRequestor, JsonMapper jsonMapper, 169 Version apiVersion) { 170 this(accessToken, null, webRequestor, jsonMapper, apiVersion); 171 } 172 173 /** 174 * Creates a Facebook Graph API client with the given {@code accessToken}, {@code webRequestor}, and 175 * {@code jsonMapper}. 176 * 177 * @param accessToken 178 * A Facebook OAuth access token. 179 * @param appSecret 180 * A Facebook application secret. 181 * @param webRequestor 182 * The {@link WebRequestor} implementation to use for sending requests to the API endpoint. 183 * @param jsonMapper 184 * The {@link JsonMapper} implementation to use for mapping API response JSON to Java objects. 185 * @param apiVersion 186 * Version of the api endpoint 187 * @throws NullPointerException 188 * If {@code jsonMapper} or {@code webRequestor} is {@code null}. 189 */ 190 public DefaultFacebookClient(String accessToken, String appSecret, WebRequestor webRequestor, JsonMapper jsonMapper, 191 Version apiVersion) { 192 super(); 193 194 verifyParameterPresence("jsonMapper", jsonMapper); 195 verifyParameterPresence("webRequestor", webRequestor); 196 197 this.accessToken = trimToNull(accessToken); 198 this.appSecret = trimToNull(appSecret); 199 200 this.webRequestor = webRequestor; 201 this.jsonMapper = jsonMapper; 202 this.jsonMapper.setFacebookClient(this); 203 this.apiVersion = Optional.ofNullable(apiVersion).orElse(Version.UNVERSIONED); 204 graphFacebookExceptionGenerator = new DefaultFacebookExceptionGenerator(); 205 } 206 207 /** 208 * Switch between access token in header and access token in query parameters (default) 209 * 210 * @param accessTokenInHttpHeader 211 * <code>true</code> use access token as header field, <code>false</code> use access token as query parameter 212 * (default) 213 */ 214 public void setHeaderAuthorization(boolean accessTokenInHttpHeader) { 215 this.accessTokenInHeader = accessTokenInHttpHeader; 216 } 217 218 /** 219 * override the default facebook exception generator to provide a custom handling for the facebook error objects 220 * 221 * @param exceptionGenerator 222 * the custom exception generator implementing the {@link FacebookExceptionGenerator} interface 223 */ 224 public void setFacebookExceptionGenerator(FacebookExceptionGenerator exceptionGenerator) { 225 graphFacebookExceptionGenerator = exceptionGenerator; 226 } 227 228 /** 229 * fetch the current facebook exception generator implementing the {@link FacebookExceptionGenerator} interface 230 * 231 * @return the current facebook exception generator 232 */ 233 public FacebookExceptionGenerator getFacebookExceptionGenerator() { 234 return graphFacebookExceptionGenerator; 235 } 236 237 @Override 238 public boolean deleteObject(String object, Parameter... parameters) { 239 verifyParameterPresence("object", object); 240 241 String responseString = makeRequest(object, true, true, null, parameters); 242 243 try { 244 JsonValue jObj = Json.parse(responseString); 245 boolean success = false; 246 if (jObj.isObject()) { 247 if (jObj.asObject().contains("success")) { 248 success = jObj.asObject().get("success").asBoolean(); 249 } 250 if (jObj.asObject().contains("result")) { 251 success = jObj.asObject().get("result").asString().contains("Successfully deleted"); 252 } 253 } else { 254 success = jObj.asBoolean(); 255 } 256 return success; 257 } catch (ParseException jex) { 258 CLIENT_LOGGER.trace("no valid JSON returned while deleting a object, using returned String instead", jex); 259 return "true".equals(responseString); 260 } 261 } 262 263 /** 264 * @see com.restfb.FacebookClient#fetchConnection(java.lang.String, java.lang.Class, com.restfb.Parameter[]) 265 */ 266 @Override 267 public <T> Connection<T> fetchConnection(String connection, Class<T> connectionType, Parameter... parameters) { 268 verifyParameterPresence(CONNECTION, connection); 269 verifyParameterPresence(CONNECTION_TYPE, connectionType); 270 return new Connection<>(this, makeRequest(connection, parameters), connectionType); 271 } 272 273 /** 274 * @see com.restfb.FacebookClient#fetchConnectionPage(java.lang.String, java.lang.Class) 275 */ 276 @Override 277 public <T> Connection<T> fetchConnectionPage(final String connectionPageUrl, Class<T> connectionType) { 278 String connectionJson; 279 if (!isBlank(accessToken) && !isBlank(appSecret)) { 280 WebRequestor.Request request = new WebRequestor.Request(String.format("%s&%s=%s", connectionPageUrl, 281 urlEncode(APP_SECRET_PROOF_PARAM_NAME), obtainAppSecretProof(accessToken, appSecret)), null); 282 connectionJson = makeRequestAndProcessResponse(() -> webRequestor.executeGet(request)); 283 } else { 284 connectionJson = makeRequestAndProcessResponse( 285 () -> webRequestor.executeGet(new WebRequestor.Request(connectionPageUrl, getHeaderAccessToken()))); 286 } 287 288 return new Connection<>(this, connectionJson, connectionType); 289 } 290 291 /** 292 * @see com.restfb.FacebookClient#fetchObject(java.lang.String, java.lang.Class, com.restfb.Parameter[]) 293 */ 294 @Override 295 public <T> T fetchObject(String object, Class<T> objectType, Parameter... parameters) { 296 verifyParameterPresence("object", object); 297 verifyParameterPresence("objectType", objectType); 298 return jsonMapper.toJavaObject(makeRequest(object, parameters), objectType); 299 } 300 301 @Override 302 public FacebookClient createClientWithAccessToken(String accessToken) { 303 return new DefaultFacebookClient(accessToken, this.appSecret, this.apiVersion); 304 } 305 306 /** 307 * @see com.restfb.FacebookClient#fetchObjects(java.util.List, java.lang.Class, com.restfb.Parameter[]) 308 */ 309 @Override 310 @SuppressWarnings("unchecked") 311 public <T> T fetchObjects(List<String> ids, Class<T> objectType, Parameter... parameters) { 312 verifyParameterPresence("ids", ids); 313 verifyParameterPresence(CONNECTION_TYPE, objectType); 314 requireNotEmpty(ids, "The list of IDs cannot be empty."); 315 316 if (Stream.of(parameters).anyMatch(p -> IDS_PARAM_NAME.equals(p.name))) { 317 throw new IllegalArgumentException("You cannot specify the '" + IDS_PARAM_NAME + "' URL parameter yourself - " 318 + "RestFB will populate this for you with the list of IDs you passed to this method."); 319 } 320 321 JsonArray idArray = new JsonArray(); 322 323 // Normalize the IDs 324 for (String id : ids) { 325 throwIAEonBlankId(id); 326 idArray.add(id.trim()); 327 } 328 329 try { 330 String jsonString = makeRequest("", 331 parametersWithAdditionalParameter(Parameter.with(IDS_PARAM_NAME, idArray.toString()), parameters)); 332 333 return jsonMapper.toJavaObject(jsonString, objectType); 334 } catch (ParseException e) { 335 throw new FacebookJsonMappingException("Unable to map connection JSON to Java objects", e); 336 } 337 } 338 339 private void throwIAEonBlankId(String id) { 340 if (StringUtils.isBlank(id)) { 341 throw new IllegalArgumentException("The list of IDs cannot contain blank strings."); 342 } 343 } 344 345 /** 346 * @see com.restfb.FacebookClient#publish(java.lang.String, java.lang.Class, com.restfb.BinaryAttachment, 347 * com.restfb.Parameter[]) 348 */ 349 @Override 350 public <T> T publish(String connection, Class<T> objectType, List<BinaryAttachment> binaryAttachments, 351 Parameter... parameters) { 352 verifyParameterPresence(CONNECTION, connection); 353 354 return jsonMapper.toJavaObject(makeRequest(connection, true, false, binaryAttachments, parameters), objectType); 355 } 356 357 /** 358 * @see com.restfb.FacebookClient#publish(java.lang.String, java.lang.Class, com.restfb.BinaryAttachment, 359 * com.restfb.Parameter[]) 360 */ 361 @Override 362 public <T> T publish(String connection, Class<T> objectType, BinaryAttachment binaryAttachment, 363 Parameter... parameters) { 364 List<BinaryAttachment> attachments = 365 Optional.ofNullable(binaryAttachment).map(Collections::singletonList).orElse(null); 366 return publish(connection, objectType, attachments, parameters); 367 } 368 369 /** 370 * @see com.restfb.FacebookClient#publish(java.lang.String, java.lang.Class, com.restfb.Parameter[]) 371 */ 372 @Override 373 public <T> T publish(String connection, Class<T> objectType, Parameter... parameters) { 374 return publish(connection, objectType, (List<BinaryAttachment>) null, parameters); 375 } 376 377 @Override 378 public <T> T publish(String connection, Class<T> objectType, Body body, Parameter... parameters) { 379 verifyParameterPresence(CONNECTION, connection); 380 return jsonMapper.toJavaObject(makeRequest(connection, true, false, null, body, parameters), objectType); 381 } 382 383 @Override 384 public String getLogoutUrl(String next) { 385 String parameterString; 386 if (next != null) { 387 Parameter p = Parameter.with("next", next); 388 parameterString = toParameterString(false, p); 389 } else { 390 parameterString = toParameterString(false); 391 } 392 393 final String fullEndPoint = createEndpointForApiCall("logout.php", false, false); 394 return fullEndPoint + "?" + parameterString; 395 } 396 397 /** 398 * @see com.restfb.FacebookClient#executeBatch(com.restfb.batch.BatchRequest[]) 399 */ 400 @Override 401 public List<BatchResponse> executeBatch(BatchRequest... batchRequests) { 402 return executeBatch(asList(batchRequests), Collections.emptyList()); 403 } 404 405 /** 406 * @see com.restfb.FacebookClient#executeBatch(java.util.List) 407 */ 408 @Override 409 public List<BatchResponse> executeBatch(List<BatchRequest> batchRequests) { 410 return executeBatch(batchRequests, Collections.emptyList()); 411 } 412 413 /** 414 * @see com.restfb.FacebookClient#executeBatch(java.util.List, java.util.List) 415 */ 416 @Override 417 public List<BatchResponse> executeBatch(List<BatchRequest> batchRequests, List<BinaryAttachment> binaryAttachments) { 418 verifyParameterPresence("binaryAttachments", binaryAttachments); 419 requireNotEmpty(batchRequests, "You must specify at least one batch request."); 420 421 return jsonMapper.toJavaList( 422 makeRequest("", true, false, binaryAttachments, Parameter.with("batch", jsonMapper.toJson(batchRequests, true))), 423 BatchResponse.class); 424 } 425 426 /** 427 * @see com.restfb.FacebookClient#convertSessionKeysToAccessTokens(java.lang.String, java.lang.String, 428 * java.lang.String[]) 429 */ 430 @Override 431 public List<AccessToken> convertSessionKeysToAccessTokens(String appId, String secretKey, String... sessionKeys) { 432 verifyParameterPresence(APP_ID, appId); 433 verifyParameterPresence("secretKey", secretKey); 434 435 if (sessionKeys == null || sessionKeys.length == 0) { 436 return emptyList(); 437 } 438 439 String json = makeRequest("/oauth/exchange_sessions", true, false, null, Parameter.with(CLIENT_ID, appId), 440 Parameter.with(PARAM_CLIENT_SECRET, secretKey), Parameter.with("sessions", String.join(",", sessionKeys))); 441 442 return jsonMapper.toJavaList(json, AccessToken.class); 443 } 444 445 /** 446 * @see com.restfb.FacebookClient#obtainAppAccessToken(java.lang.String, java.lang.String) 447 */ 448 @Override 449 public AccessToken obtainAppAccessToken(String appId, String appSecret) { 450 verifyParameterPresence(APP_ID, appId); 451 verifyParameterPresence(APP_SECRET, appSecret); 452 453 String response = makeRequest("oauth/access_token", Parameter.with("grant_type", "client_credentials"), 454 Parameter.with(CLIENT_ID, appId), Parameter.with(PARAM_CLIENT_SECRET, appSecret)); 455 456 try { 457 return getAccessTokenFromResponse(response); 458 } catch (Exception t) { 459 throw new FacebookResponseContentException(CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE, t); 460 } 461 } 462 463 @Override 464 public DeviceCode fetchDeviceCode(ScopeBuilder scope) { 465 verifyParameterPresence(SCOPE, scope); 466 ObjectUtil.requireNotNull(accessToken, 467 () -> new IllegalStateException("access token is required to fetch a device access token")); 468 469 String response = makeRequest("device/login", true, false, null, Parameter.with("type", "device_code"), 470 Parameter.with(SCOPE, scope.toString())); 471 return jsonMapper.toJavaObject(response, DeviceCode.class); 472 } 473 474 @Override 475 public AccessToken obtainDeviceAccessToken(String code) throws FacebookDeviceTokenCodeExpiredException, 476 FacebookDeviceTokenPendingException, FacebookDeviceTokenDeclinedException, FacebookDeviceTokenSlowdownException { 477 verifyParameterPresence("code", code); 478 479 ObjectUtil.requireNotNull(accessToken, 480 () -> new IllegalStateException("access token is required to fetch a device access token")); 481 482 try { 483 String response = makeRequest("device/login_status", true, false, null, Parameter.with("type", "device_token"), 484 Parameter.with("code", code)); 485 return getAccessTokenFromResponse(response); 486 } catch (FacebookOAuthException foae) { 487 DeviceTokenExceptionFactory.createFrom(foae); 488 return null; 489 } 490 } 491 492 /** 493 * @see com.restfb.FacebookClient#obtainUserAccessToken(java.lang.String, java.lang.String, java.lang.String, 494 * java.lang.String) 495 */ 496 @Override 497 public AccessToken obtainUserAccessToken(String appId, String appSecret, String redirectUri, 498 String verificationCode) { 499 verifyParameterPresence(APP_ID, appId); 500 verifyParameterPresence(APP_SECRET, appSecret); 501 verifyParameterPresence("verificationCode", verificationCode); 502 503 String response = makeRequest("oauth/access_token", Parameter.with(CLIENT_ID, appId), 504 Parameter.with(PARAM_CLIENT_SECRET, appSecret), Parameter.with("code", verificationCode), 505 Parameter.with("redirect_uri", redirectUri)); 506 507 try { 508 return getAccessTokenFromResponse(response); 509 } catch (Exception t) { 510 throw new FacebookResponseContentException(CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE, t); 511 } 512 } 513 514 /** 515 * @see com.restfb.FacebookClient#obtainExtendedAccessToken(java.lang.String, java.lang.String) 516 */ 517 @Override 518 public AccessToken obtainExtendedAccessToken(String appId, String appSecret) { 519 ObjectUtil.requireNotNull(accessToken, 520 () -> new IllegalStateException( 521 format("You cannot call this method because you did not construct this instance of %s with an access token.", 522 getClass().getSimpleName()))); 523 524 return obtainExtendedAccessToken(appId, appSecret, accessToken); 525 } 526 527 /** 528 * @see com.restfb.FacebookClient#obtainExtendedAccessToken(java.lang.String, java.lang.String, java.lang.String) 529 */ 530 @Override 531 public AccessToken obtainExtendedAccessToken(String appId, String appSecret, String accessToken) { 532 verifyParameterPresence(APP_ID, appId); 533 verifyParameterPresence(APP_SECRET, appSecret); 534 verifyParameterPresence("accessToken", accessToken); 535 536 String response = makeRequest("/oauth/access_token", false, false, null, Parameter.with(CLIENT_ID, appId), 537 Parameter.with(PARAM_CLIENT_SECRET, appSecret), Parameter.with("grant_type", "fb_exchange_token"), 538 Parameter.with("fb_exchange_token", accessToken)); 539 540 try { 541 return getAccessTokenFromResponse(response); 542 } catch (Exception t) { 543 throw new FacebookResponseContentException(CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE, t); 544 } 545 } 546 547 private AccessToken getAccessTokenFromResponse(String response) { 548 AccessToken token; 549 try { 550 token = getJsonMapper().toJavaObject(response, AccessToken.class); 551 } catch (FacebookJsonMappingException fjme) { 552 CLIENT_LOGGER.trace("could not map response to access token class try to fetch directly from String", fjme); 553 token = AccessToken.fromQueryString(response); 554 } 555 token.setClient(createClientWithAccessToken(token.getAccessToken())); 556 return token; 557 } 558 559 @Override 560 @SuppressWarnings("unchecked") 561 public <T> T parseSignedRequest(String signedRequest, String appSecret, Class<T> objectType) { 562 verifyParameterPresence("signedRequest", signedRequest); 563 verifyParameterPresence(APP_SECRET, appSecret); 564 verifyParameterPresence("objectType", objectType); 565 566 String[] signedRequestTokens = signedRequest.split("[.]"); 567 568 if (signedRequestTokens.length != 2) { 569 throw new FacebookSignedRequestParsingException(format( 570 "Signed request '%s' is expected to be signature and payload strings separated by a '.'", signedRequest)); 571 } 572 573 String encodedSignature = signedRequestTokens[0]; 574 String urlDecodedSignature = urlDecodeSignedRequestToken(encodedSignature); 575 byte[] signature = decodeBase64(urlDecodedSignature); 576 577 String encodedPayload = signedRequestTokens[1]; 578 String urlDecodedPayload = urlDecodeSignedRequestToken(encodedPayload); 579 String payload = StringUtils.toString(decodeBase64(urlDecodedPayload)); 580 581 // Convert payload to a JsonObject so we can pull algorithm data out of it 582 JsonObject payloadObject = getJsonMapper().toJavaObject(payload, JsonObject.class); 583 584 if (!payloadObject.contains(ALGORITHM)) { 585 throw new FacebookSignedRequestParsingException("Unable to detect algorithm used for signed request"); 586 } 587 588 String algorithm = payloadObject.getString(ALGORITHM, null); 589 590 if (!verifySignedRequest(appSecret, algorithm, encodedPayload, signature)) { 591 throw new FacebookSignedRequestVerificationException( 592 "Signed request verification failed. Are you sure the request was made for the app identified by the app secret you provided?"); 593 } 594 595 // Marshal to the user's preferred type. 596 // If the user asked for a JsonObject, send back the one we already parsed. 597 return objectType.equals(JsonObject.class) ? (T) payloadObject : getJsonMapper().toJavaObject(payload, objectType); 598 } 599 600 /** 601 * Decodes a component of a signed request received from Facebook using FB's special URL-encoding strategy. 602 * 603 * @param signedRequestToken 604 * Token to decode. 605 * @return The decoded token. 606 */ 607 protected String urlDecodeSignedRequestToken(String signedRequestToken) { 608 verifyParameterPresence("signedRequestToken", signedRequestToken); 609 return signedRequestToken.replace("-", "+").replace("_", "/").trim(); 610 } 611 612 @Override 613 public String getLoginDialogUrl(String appId, String redirectUri, ScopeBuilder scope, Parameter... parameters) { 614 verifyParameterPresence(APP_ID, appId); 615 verifyParameterPresence("redirectUri", redirectUri); 616 verifyParameterPresence(SCOPE, scope); 617 618 String dialogUrl = getFacebookEndpointUrls().getFacebookEndpoint() + "/dialog/oauth"; 619 620 List<Parameter> parameterList = new ArrayList<>(); 621 parameterList.add(Parameter.with(CLIENT_ID, appId)); 622 parameterList.add(Parameter.with("redirect_uri", redirectUri)); 623 parameterList.add(Parameter.with(SCOPE, scope.toString())); 624 625 // add optional parameters 626 Collections.addAll(parameterList, parameters); 627 628 return dialogUrl + "?" + toParameterString(false, parameterList.toArray(new Parameter[0])); 629 } 630 631 /** 632 * Verifies that the signed request is really from Facebook. 633 * 634 * @param appSecret 635 * The secret for the app that can verify this signed request. 636 * @param algorithm 637 * Signature algorithm specified by FB in the decoded payload. 638 * @param encodedPayload 639 * The encoded payload used to generate a signature for comparison against the provided {@code signature}. 640 * @param signature 641 * The decoded signature extracted from the signed request. Compared against a signature generated from 642 * {@code encodedPayload}. 643 * @return {@code true} if the signed request is verified, {@code false} if not. 644 */ 645 protected boolean verifySignedRequest(String appSecret, String algorithm, String encodedPayload, byte[] signature) { 646 verifyParameterPresence(APP_SECRET, appSecret); 647 verifyParameterPresence(ALGORITHM, algorithm); 648 verifyParameterPresence("encodedPayload", encodedPayload); 649 verifyParameterPresence("signature", signature); 650 651 // Normalize algorithm name...FB calls it differently than Java does 652 if ("HMAC-SHA256".equalsIgnoreCase(algorithm)) { 653 algorithm = "HMACSHA256"; 654 } 655 656 try { 657 Mac mac = Mac.getInstance(algorithm); 658 mac.init(new SecretKeySpec(toBytes(appSecret), algorithm)); 659 byte[] payloadSignature = mac.doFinal(toBytes(encodedPayload)); 660 return Arrays.equals(signature, payloadSignature); 661 } catch (Exception e) { 662 throw new FacebookSignedRequestVerificationException("Unable to perform signed request verification", e); 663 } 664 } 665 666 /** 667 * @see com.restfb.FacebookClient#debugToken(java.lang.String) 668 */ 669 @Override 670 public DebugTokenInfo debugToken(String inputToken) { 671 verifyParameterPresence("inputToken", inputToken); 672 String response = makeRequest("/debug_token", Parameter.with("input_token", inputToken)); 673 674 try { 675 JsonObject json = Json.parse(response).asObject(); 676 677 // FB sometimes returns an empty DebugTokenInfo and then the 'data' field is an array 678 if (json.contains("data") && json.get("data").isArray()) { 679 return null; 680 } 681 682 JsonObject data = json.get("data").asObject(); 683 return getJsonMapper().toJavaObject(data.toString(), DebugTokenInfo.class); 684 } catch (Exception t) { 685 throw new FacebookResponseContentException("Unable to parse JSON from response.", t); 686 } 687 } 688 689 /** 690 * @see com.restfb.FacebookClient#getJsonMapper() 691 */ 692 @Override 693 public JsonMapper getJsonMapper() { 694 return jsonMapper; 695 } 696 697 /** 698 * @see com.restfb.FacebookClient#getWebRequestor() 699 */ 700 @Override 701 public WebRequestor getWebRequestor() { 702 return webRequestor; 703 } 704 705 /** 706 * Coordinates the process of executing the API request GET/POST and processing the response we receive from the 707 * endpoint. 708 * 709 * @param endpoint 710 * Facebook Graph API endpoint. 711 * @param parameters 712 * Arbitrary number of parameters to send along to Facebook as part of the API call. 713 * @return The JSON returned by Facebook for the API call. 714 * @throws FacebookException 715 * If an error occurs while making the Facebook API POST or processing the response. 716 */ 717 protected String makeRequest(String endpoint, Parameter... parameters) { 718 return makeRequest(endpoint, false, false, null, parameters); 719 } 720 721 protected String makeRequest(String endpoint, final boolean executeAsPost, final boolean executeAsDelete, 722 final List<BinaryAttachment> binaryAttachments, Parameter... parameters) { 723 return makeRequest(endpoint, executeAsPost, executeAsDelete, 724 binaryAttachments, null, parameters); 725 } 726 727 /** 728 * Coordinates the process of executing the API request GET/POST and processing the response we receive from the 729 * endpoint. 730 * 731 * @param endpoint 732 * Facebook Graph API endpoint. 733 * @param executeAsPost 734 * {@code true} to execute the web request as a {@code POST}, {@code false} to execute as a {@code GET}. 735 * @param executeAsDelete 736 * {@code true} to add a special 'treat this request as a {@code DELETE}' parameter. 737 * @param binaryAttachments 738 * A list of binary files to include in a {@code POST} request. Pass {@code null} if no attachment should be 739 * sent. 740 * @param parameters 741 * Arbitrary number of parameters to send along to Facebook as part of the API call. 742 * @return The JSON returned by Facebook for the API call. 743 * @throws FacebookException 744 * If an error occurs while making the Facebook API POST or processing the response. 745 */ 746 protected String makeRequest(String endpoint, final boolean executeAsPost, final boolean executeAsDelete, 747 final List<BinaryAttachment> binaryAttachments, Body body, Parameter... parameters) { 748 verifyParameterLegality(parameters); 749 750 if (executeAsDelete && isHttpDeleteFallback()) { 751 parameters = parametersWithAdditionalParameter(Parameter.with(METHOD_PARAM_NAME, "delete"), parameters); 752 } 753 754 if (!endpoint.startsWith("/")) { 755 endpoint = "/" + endpoint; 756 } 757 758 boolean hasAttachment = binaryAttachments != null && !binaryAttachments.isEmpty(); 759 boolean hasReel = hasAttachment && binaryAttachments.get(0).isFacebookReel(); 760 761 final String fullEndpoint = 762 createEndpointForApiCall(endpoint, hasAttachment, hasReel); 763 final String parameterString = toParameterString(parameters); 764 765 String headerAccessToken = (hasReel) ? accessToken : getHeaderAccessToken(); 766 767 return makeRequestAndProcessResponse(() -> { 768 WebRequestor.Request request = new WebRequestor.Request(fullEndpoint, headerAccessToken, parameterString); 769 if (executeAsDelete && !isHttpDeleteFallback()) { 770 return webRequestor.executeDelete(request); 771 } 772 773 if (executeAsPost) { 774 request.setBinaryAttachments(binaryAttachments); 775 request.setBody(body); 776 return webRequestor.executePost(request); 777 } 778 779 return webRequestor.executeGet(request); 780 }); 781 } 782 783 private String getHeaderAccessToken() { 784 if (accessTokenInHeader) { 785 return this.accessToken; 786 } 787 788 return null; 789 } 790 791 /** 792 * @see com.restfb.FacebookClient#obtainAppSecretProof(java.lang.String, java.lang.String) 793 */ 794 @Override 795 public String obtainAppSecretProof(String accessToken, String appSecret) { 796 verifyParameterPresence("accessToken", accessToken); 797 verifyParameterPresence(APP_SECRET, appSecret); 798 return EncodingUtils.encodeAppSecretProof(appSecret, accessToken); 799 } 800 801 /** 802 * returns if the fallback post method (<code>true</code>) is used or the http delete (<code>false</code>) 803 * 804 * @return {@code true} if POST is used instead of HTTP DELETE (default) 805 */ 806 public boolean isHttpDeleteFallback() { 807 return httpDeleteFallback; 808 } 809 810 /** 811 * Set to <code>true</code> if the facebook http delete fallback should be used. Facebook allows to use the http POST 812 * with the parameter "method=delete" to override the post and use delete instead. This feature allow http client that 813 * do not support the whole http method set, to delete objects from facebook 814 * 815 * @param httpDeleteFallback 816 * <code>true</code> if the the http Delete Fallback is used 817 */ 818 public void setHttpDeleteFallback(boolean httpDeleteFallback) { 819 this.httpDeleteFallback = httpDeleteFallback; 820 } 821 822 protected interface Requestor { 823 Response makeRequest() throws IOException; 824 } 825 826 protected String makeRequestAndProcessResponse(Requestor requestor) { 827 Response response; 828 829 // Perform a GET or POST to the API endpoint 830 try { 831 response = requestor.makeRequest(); 832 } catch (Exception t) { 833 throw new FacebookNetworkException(t); 834 } 835 836 // If we get any HTTP response code other than a 200 OK or 400 Bad Request 837 // or 401 Not Authorized or 403 Forbidden or 404 Not Found or 500 Internal 838 // Server Error or 302 Not Modified 839 // throw an exception. 840 if (HTTP_OK != response.getStatusCode() && HTTP_BAD_REQUEST != response.getStatusCode() 841 && HTTP_UNAUTHORIZED != response.getStatusCode() && HTTP_NOT_FOUND != response.getStatusCode() 842 && HTTP_INTERNAL_ERROR != response.getStatusCode() && HTTP_FORBIDDEN != response.getStatusCode() 843 && HTTP_NOT_MODIFIED != response.getStatusCode()) { 844 throw new FacebookNetworkException(response.getStatusCode()); 845 } 846 847 String json = response.getBody(); 848 849 try { 850 // If the response contained an error code, throw an exception. 851 getFacebookExceptionGenerator().throwFacebookResponseStatusExceptionIfNecessary(json, response.getStatusCode()); 852 } catch (FacebookErrorMessageException feme) { 853 Optional.ofNullable(getWebRequestor()).map(WebRequestor::getDebugHeaderInfo).ifPresent(feme::setDebugHeaderInfo); 854 throw feme; 855 } 856 857 // If there was no response error information and this was a 500 or 401 858 // error, something weird happened on Facebook's end. Bail. 859 if (HTTP_INTERNAL_ERROR == response.getStatusCode() || HTTP_UNAUTHORIZED == response.getStatusCode()) { 860 throw new FacebookNetworkException(response.getStatusCode()); 861 } 862 863 return json; 864 } 865 866 /** 867 * Generate the parameter string to be included in the Facebook API request. 868 * 869 * @param parameters 870 * Arbitrary number of extra parameters to include in the request. 871 * @return The parameter string to include in the Facebook API request. 872 * @throws FacebookJsonMappingException 873 * If an error occurs when building the parameter string. 874 */ 875 protected String toParameterString(Parameter... parameters) { 876 return toParameterString(true, parameters); 877 } 878 879 /** 880 * Generate the parameter string to be included in the Facebook API request. 881 * 882 * @param withJsonParameter 883 * add additional parameter format with type json 884 * @param parameters 885 * Arbitrary number of extra parameters to include in the request. 886 * @return The parameter string to include in the Facebook API request. 887 * @throws FacebookJsonMappingException 888 * If an error occurs when building the parameter string. 889 */ 890 protected String toParameterString(boolean withJsonParameter, Parameter... parameters) { 891 if (!isBlank(accessToken) && !accessTokenInHeader) { 892 parameters = parametersWithAdditionalParameter(Parameter.with(ACCESS_TOKEN_PARAM_NAME, accessToken), parameters); 893 } 894 895 if (!isBlank(accessToken) && !isBlank(appSecret)) { 896 parameters = parametersWithAdditionalParameter( 897 Parameter.with(APP_SECRET_PROOF_PARAM_NAME, obtainAppSecretProof(accessToken, appSecret)), parameters); 898 } 899 900 if (withJsonParameter) { 901 parameters = parametersWithAdditionalParameter(Parameter.with(FORMAT_PARAM_NAME, "json"), parameters); 902 } 903 904 return Stream.of(parameters).map(p -> urlEncode(p.name) + "=" + urlEncodedValueForParameterName(p.name, p.value)) 905 .collect(Collectors.joining("&")); 906 } 907 908 /** 909 * @see com.restfb.BaseFacebookClient#createEndpointForApiCall(java.lang.String,boolean,boolean) 910 */ 911 @Override 912 protected String createEndpointForApiCall(String apiCall, boolean hasAttachment, boolean hasReel) { 913 while (apiCall.startsWith("/")) { 914 apiCall = apiCall.substring(1); 915 } 916 917 String baseUrl = getFacebookGraphEndpointUrl(); 918 919 if (hasAttachment && hasReel) { 920 baseUrl = getFacebookReelsUploadEndpointUrl(); 921 } else if (hasAttachment && (apiCall.endsWith("/videos") || apiCall.endsWith("/advideos"))) { 922 baseUrl = getFacebookGraphVideoEndpointUrl(); 923 } else if (apiCall.endsWith("logout.php")) { 924 baseUrl = getFacebookEndpointUrls().getFacebookEndpoint(); 925 } 926 927 return format("%s/%s", baseUrl, apiCall); 928 } 929 930 /** 931 * Returns the base endpoint URL for the Graph API. 932 * 933 * @return The base endpoint URL for the Graph API. 934 */ 935 protected String getFacebookGraphEndpointUrl() { 936 if (apiVersion.isUrlElementRequired()) { 937 return getFacebookEndpointUrls().getGraphEndpoint() + '/' + apiVersion.getUrlElement(); 938 } else { 939 return getFacebookEndpointUrls().getGraphEndpoint(); 940 } 941 } 942 943 /** 944 * Returns the base endpoint URL for the Graph API's video upload functionality. 945 * 946 * @return The base endpoint URL for the Graph API's video upload functionality. 947 * @since 1.6.5 948 */ 949 protected String getFacebookGraphVideoEndpointUrl() { 950 if (apiVersion.isUrlElementRequired()) { 951 return getFacebookEndpointUrls().getGraphVideoEndpoint() + '/' + apiVersion.getUrlElement(); 952 } else { 953 return getFacebookEndpointUrls().getGraphVideoEndpoint(); 954 } 955 } 956 957 /** 958 * Returns the Facebook Reels Upload endpoint URL for handling the Reels Upload 959 * @return the Facebook Reels Upload endpoint URL 960 */ 961 protected String getFacebookReelsUploadEndpointUrl() { 962 if (apiVersion.isUrlElementRequired()) { 963 return getFacebookEndpointUrls().getReelUploadEndpoint() + "/" + apiVersion.getUrlElement(); 964 } 965 966 return getFacebookEndpointUrls().getReelUploadEndpoint(); 967 } 968 969 public FacebookEndpoints getFacebookEndpointUrls() { 970 return facebookEndpointUrls; 971 } 972 973 public void setFacebookEndpointUrls(FacebookEndpoints facebookEndpointUrls) { 974 this.facebookEndpointUrls = facebookEndpointUrls; 975 } 976}