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