--- /dev/null
+/*
+ * Copyright 2015 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.social.facebook.web;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.List;
+import java.util.Map;
+
+import javax.inject.Inject;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.social.connect.Connection;
+import org.springframework.social.connect.ConnectionFactoryLocator;
+import org.springframework.social.connect.UsersConnectionRepository;
+import org.springframework.social.connect.support.OAuth2ConnectionFactory;
+import org.springframework.social.connect.web.SignInAdapter;
+import org.springframework.social.facebook.api.Facebook;
+import org.springframework.social.oauth2.AccessGrant;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.context.request.NativeWebRequest;
+import org.springframework.web.servlet.View;
+import org.springframework.web.servlet.view.RedirectView;
+
+/**
+ * Sign in controller that uses the signed_request parameter that Facebook gives to Canvas applications to obtain an access token.
+ * If no access token exists in signed_request, this controller will redirect the top-level browser window to Facebook's authorization dialog.
+ * When Facebook redirects back from the authorization dialog, the signed_request parameter should contain an access token.
+ * @author Craig Walls
+ */
+@Controller
+@RequestMapping(value="/canvas")
+public class CanvasSignInController {
+
+ private final static Log logger = LogFactory.getLog(CanvasSignInController.class);
+
+ private final String clientId;
+
+ private final String canvasPage;
+
+ private final ConnectionFactoryLocator connectionFactoryLocator;
+
+ private final UsersConnectionRepository usersConnectionRepository;
+
+ private final SignInAdapter signInAdapter;
+
+ private final SignedRequestDecoder signedRequestDecoder;
+
+ private String postSignInUrl = "/";
+
+ private String postDeclineUrl = "http://www.facebook.com";
+
+ private String scope;
+
+ @Inject
+ public CanvasSignInController(ConnectionFactoryLocator connectionFactoryLocator, UsersConnectionRepository usersConnectionRepository, SignInAdapter signInAdapter, String clientId, String clientSecret, String canvasPage) {
+ this.usersConnectionRepository = usersConnectionRepository;
+ this.signInAdapter = signInAdapter;
+ this.clientId = clientId;
+ this.canvasPage = canvasPage;
+ this.connectionFactoryLocator = connectionFactoryLocator;
+ this.signedRequestDecoder = new SignedRequestDecoder(clientSecret);
+ }
+
+ /**
+ * The URL or path to redirect to after successful canvas authorization.
+ * @param postSignInUrl the url to redirect to after successful canvas authorization. Defaults to "/".
+ */
+ public void setPostSignInUrl(String postSignInUrl) {
+ this.postSignInUrl = postSignInUrl;
+ }
+
+ /**
+ * The URL or path to redirect to if a user declines authorization.
+ * The redirect will happen in the top-level window.
+ * If you want the redirect to happen in the canvas iframe, then override the {@link #postDeclineView()} method to return a different implementation of {@link View}.
+ * @param postDeclineUrl the url to redirect to after a user declines authorization. Defaults to "http://www.facebook.com".
+ */
+ public void setPostDeclineUrl(String postDeclineUrl) {
+ this.postDeclineUrl = postDeclineUrl;
+ }
+
+ /**
+ * The scope to request during authorization.
+ * @param scope the scope to request. Defaults to null (no scope will be requested; Facebook will offer their default scope).
+ */
+ public void setScope(String scope) {
+ this.scope = scope;
+ }
+
+ @RequestMapping(method={ RequestMethod.POST, RequestMethod.GET }, params={"signed_request", "!error"})
+ public View signin(Model model, NativeWebRequest request) throws SignedRequestException {
+ String signedRequest = request.getParameter("signed_request");
+ if (signedRequest == null) {
+ debug("Expected a signed_request parameter, but none given. Redirecting to the application's Canvas Page: " + canvasPage);
+ return new RedirectView(canvasPage, false);
+ }
+
+ Map<String, ?> decodedSignedRequest = signedRequestDecoder.decodeSignedRequest(signedRequest);
+ String accessToken = (String) decodedSignedRequest.get("oauth_token");
+ if (accessToken == null) {
+ debug("No access token in the signed_request parameter. Redirecting to the authorization dialog.");
+ model.addAttribute("clientId", clientId);
+ model.addAttribute("canvasPage", canvasPage);
+ if (scope != null) {
+ model.addAttribute("scope", scope);
+ }
+ return new TopLevelWindowRedirect() {
+ @Override
+ protected String getRedirectUrl(Map<String, ?> model) {
+ String clientId = (String) model.get("clientId");
+ String canvasPage = (String) model.get("canvasPage");
+ String scope = (String) model.get("scope");
+ String redirectUrl = "https://www.facebook.com/v2.5/dialog/oauth?client_id=" + clientId + "&redirect_uri=" + canvasPage;
+ if (scope != null) {
+ redirectUrl += "&scope=" + formEncode(scope);
+ }
+ return redirectUrl;
+ }
+ };
+ }
+
+ debug("Access token available in signed_request parameter. Creating connection and signing in.");
+ OAuth2ConnectionFactory<Facebook> connectionFactory = (OAuth2ConnectionFactory<Facebook>) connectionFactoryLocator.getConnectionFactory(Facebook.class);
+ AccessGrant accessGrant = new AccessGrant(accessToken);
+ // TODO: Maybe should create via ConnectionData instead?
+ Connection<Facebook> connection = connectionFactory.createConnection(accessGrant);
+ handleSignIn(connection, request);
+ debug("Signed in. Redirecting to post-signin page.");
+ return new RedirectView(postSignInUrl, true);
+ }
+
+ @RequestMapping(method={ RequestMethod.POST, RequestMethod.GET }, params="error")
+ public View error(@RequestParam("error") String error, @RequestParam("error_description") String errorDescription) {
+ String string = "User declined authorization: '" + errorDescription + "'. Redirecting to " + postDeclineUrl;
+ debug(string);
+ return postDeclineView();
+ }
+
+ /**
+ * View that redirects the top level window to the URL defined in postDeclineUrl property after user declines to authorize application.
+ * May be overridden for custom views, particularly in the case where the post-decline view should be rendered in-canvas.
+ * @return a view to display after a user declines authoriation. Defaults as a redirect to postDeclineUrl
+ */
+ protected View postDeclineView() {
+ return new TopLevelWindowRedirect() {
+ @Override
+ protected String getRedirectUrl(Map<String, ?> model) {
+ return postDeclineUrl;
+ }
+ };
+ }
+
+ private void debug(String string) {
+ if (logger.isDebugEnabled()) {
+ logger.debug(string);
+ }
+ }
+
+ private void handleSignIn(Connection<Facebook> connection, NativeWebRequest request) {
+ List<String> userIds = usersConnectionRepository.findUserIdsWithConnection(connection);
+ if (userIds.size() == 1) {
+ usersConnectionRepository.createConnectionRepository(userIds.get(0)).updateConnection(connection);
+ signInAdapter.signIn(userIds.get(0), connection, request);
+ } else {
+ // TODO: This should never happen, but need to figure out what to do if it does happen.
+ logger.error("Expected exactly 1 matching user. Got " + userIds.size() + " metching users.");
+ }
+ }
+
+ private static abstract class TopLevelWindowRedirect implements View {
+
+ public String getContentType() {
+ return "text/html";
+ }
+
+ public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
+ response.setContentType("text/html");
+ response.getWriter().write("<script>");
+ response.getWriter().write("top.location.href='" + getRedirectUrl(model) + "';");
+ response.getWriter().write("</script>");
+ response.flushBuffer();
+ }
+
+ protected abstract String getRedirectUrl(Map<String, ?> model);
+
+ }
+
+ private String formEncode(String data) {
+ try {
+ return URLEncoder.encode(data, "UTF-8");
+ }
+ catch (UnsupportedEncodingException ex) {
+ // should not happen, UTF-8 is always supported
+ throw new IllegalStateException(ex);
+ }
+ }
+
+}