Added a fix to repair the login-redirect when using the Chrome-browser
[examples/facebook-app] / src / main / java / org / springframework / social / facebook / web / CanvasSignInController.java
1 /*
2  * Copyright 2015 the original author or authors.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package org.springframework.social.facebook.web;
17
18 import java.io.UnsupportedEncodingException;
19 import java.net.URLEncoder;
20 import java.util.List;
21 import java.util.Map;
22
23 import javax.inject.Inject;
24 import javax.servlet.http.HttpServletRequest;
25 import javax.servlet.http.HttpServletResponse;
26
27 import org.apache.commons.logging.Log;
28 import org.apache.commons.logging.LogFactory;
29 import org.springframework.social.connect.Connection;
30 import org.springframework.social.connect.ConnectionFactoryLocator;
31 import org.springframework.social.connect.UsersConnectionRepository;
32 import org.springframework.social.connect.support.OAuth2ConnectionFactory;
33 import org.springframework.social.connect.web.SignInAdapter;
34 import org.springframework.social.facebook.api.Facebook;
35 import org.springframework.social.oauth2.AccessGrant;
36 import org.springframework.stereotype.Controller;
37 import org.springframework.ui.Model;
38 import org.springframework.web.bind.annotation.RequestMapping;
39 import org.springframework.web.bind.annotation.RequestMethod;
40 import org.springframework.web.bind.annotation.RequestParam;
41 import org.springframework.web.context.request.NativeWebRequest;
42 import org.springframework.web.servlet.View;
43 import org.springframework.web.servlet.view.RedirectView;
44
45 /**
46  * Sign in controller that uses the signed_request parameter that Facebook gives to Canvas applications to obtain an access token.
47  * If no access token exists in signed_request, this controller will redirect the top-level browser window to Facebook's authorization dialog.
48  * When Facebook redirects back from the authorization dialog, the signed_request parameter should contain an access token.
49  * @author Craig Walls
50  */
51 @Controller
52 @RequestMapping(value="/canvas")
53 public class CanvasSignInController {
54         
55         private final static Log logger = LogFactory.getLog(CanvasSignInController.class);
56
57         private final String clientId;
58         
59         private final String canvasPage;
60
61         private final ConnectionFactoryLocator connectionFactoryLocator;
62
63         private final UsersConnectionRepository usersConnectionRepository;
64
65         private final SignInAdapter signInAdapter;
66         
67         private final SignedRequestDecoder signedRequestDecoder;
68         
69         private String postSignInUrl = "/";
70         
71         private String postDeclineUrl = "http://www.facebook.com";
72
73         private String scope;
74
75         @Inject
76         public CanvasSignInController(ConnectionFactoryLocator connectionFactoryLocator, UsersConnectionRepository usersConnectionRepository, SignInAdapter signInAdapter, String clientId, String clientSecret, String canvasPage) {
77                 this.usersConnectionRepository = usersConnectionRepository;
78                 this.signInAdapter = signInAdapter;
79                 this.clientId = clientId;
80                 this.canvasPage = canvasPage;
81                 this.connectionFactoryLocator = connectionFactoryLocator;
82                 this.signedRequestDecoder = new SignedRequestDecoder(clientSecret);
83         }
84         
85         /**
86          * The URL or path to redirect to after successful canvas authorization.
87          * @param postSignInUrl the url to redirect to after successful canvas authorization. Defaults to "/".
88          */
89         public void setPostSignInUrl(String postSignInUrl) {
90                 this.postSignInUrl = postSignInUrl;
91         }
92         
93         /**
94          * The URL or path to redirect to if a user declines authorization.
95          * The redirect will happen in the top-level window. 
96          * 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}.
97          * @param postDeclineUrl the url to redirect to after a user declines authorization. Defaults to "http://www.facebook.com".
98          */
99         public void setPostDeclineUrl(String postDeclineUrl) {
100                 this.postDeclineUrl = postDeclineUrl;
101         }
102         
103         /**
104          * The scope to request during authorization.
105          * @param scope the scope to request. Defaults to null (no scope will be requested; Facebook will offer their default scope).
106          */
107         public void setScope(String scope) {
108                 this.scope = scope;
109         }
110
111         @RequestMapping(method={ RequestMethod.POST, RequestMethod.GET }, params={"signed_request", "!error"})
112         public View signin(Model model, NativeWebRequest request) throws SignedRequestException {
113                 String signedRequest = request.getParameter("signed_request");
114                 if (signedRequest == null) {
115                         debug("Expected a signed_request parameter, but none given. Redirecting to the application's Canvas Page: " + canvasPage);
116                         return new RedirectView(canvasPage, false);
117                 }
118                 
119                 Map<String, ?> decodedSignedRequest = signedRequestDecoder.decodeSignedRequest(signedRequest);
120                 String accessToken = (String) decodedSignedRequest.get("oauth_token");
121                 if (accessToken == null) {
122                         debug("No access token in the signed_request parameter. Redirecting to the authorization dialog.");
123                         model.addAttribute("clientId", clientId);
124                         model.addAttribute("canvasPage", canvasPage);
125                         if (scope != null) {
126                                 model.addAttribute("scope", scope);
127                         }
128                         return new TopLevelWindowRedirect() {
129                                 @Override
130                                 protected String getRedirectUrl(Map<String, ?> model) {
131                                         String clientId = (String) model.get("clientId");
132                                         String canvasPage = (String) model.get("canvasPage");
133                                         String scope = (String) model.get("scope");
134                                         String redirectUrl = "https://www.facebook.com/v2.5/dialog/oauth?client_id=" + clientId + "&redirect_uri=" + canvasPage;
135                                         if (scope != null) {
136                                                 redirectUrl += "&scope=" + formEncode(scope);
137                                         }
138                                         return redirectUrl;
139                                 }
140                         };
141                 }
142
143                 debug("Access token available in signed_request parameter. Creating connection and signing in.");
144                 OAuth2ConnectionFactory<Facebook> connectionFactory = (OAuth2ConnectionFactory<Facebook>) connectionFactoryLocator.getConnectionFactory(Facebook.class);
145                 AccessGrant accessGrant = new AccessGrant(accessToken);
146                 // TODO: Maybe should create via ConnectionData instead?
147                 Connection<Facebook> connection = connectionFactory.createConnection(accessGrant);
148                 handleSignIn(connection, request);
149                 debug("Signed in. Redirecting to post-signin page.");
150                 return new RedirectView(postSignInUrl, true); 
151         }
152
153         @RequestMapping(method={ RequestMethod.POST, RequestMethod.GET }, params="error")
154         public View error(@RequestParam("error") String error, @RequestParam("error_description") String errorDescription) {
155                 String string = "User declined authorization: '" + errorDescription + "'. Redirecting to " + postDeclineUrl;
156                 debug(string);
157                 return postDeclineView();
158         }
159
160         /**
161          * View that redirects the top level window to the URL defined in postDeclineUrl property after user declines to authorize application.
162          * May be overridden for custom views, particularly in the case where the post-decline view should be rendered in-canvas.
163          * @return a view to display after a user declines authoriation. Defaults as a redirect to postDeclineUrl
164          */
165         protected View postDeclineView() {
166                 return new TopLevelWindowRedirect() {
167                         @Override
168                         protected String getRedirectUrl(Map<String, ?> model) {
169                                 return postDeclineUrl;
170                         }
171                 };
172         }
173         
174         private void debug(String string) {
175                 if (logger.isDebugEnabled()) {
176                         logger.debug(string);
177                 }
178         }
179
180         private void handleSignIn(Connection<Facebook> connection, NativeWebRequest request) {
181                 List<String> userIds = usersConnectionRepository.findUserIdsWithConnection(connection);
182                 if (userIds.size() == 1) {
183                         usersConnectionRepository.createConnectionRepository(userIds.get(0)).updateConnection(connection);
184                         signInAdapter.signIn(userIds.get(0), connection, request);
185                 } else {
186                         // TODO: This should never happen, but need to figure out what to do if it does happen. 
187                         logger.error("Expected exactly 1 matching user. Got " + userIds.size() + " metching users.");
188                 }
189         }
190         
191         private static abstract class TopLevelWindowRedirect implements View {
192                 
193                 public String getContentType() {
194                         return "text/html";
195                 }
196
197                 public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
198                         response.setContentType("text/html");
199                         response.getWriter().write("<script>");
200                         response.getWriter().write("top.location.href='" + getRedirectUrl(model) + "';");
201                         response.getWriter().write("</script>");
202                         response.flushBuffer();
203                 }
204                 
205                 protected abstract String getRedirectUrl(Map<String, ?> model);
206
207         }
208
209         private String formEncode(String data) {
210                 try {
211                         return URLEncoder.encode(data, "UTF-8");
212                 }
213                 catch (UnsupportedEncodingException ex) {
214                         // should not happen, UTF-8 is always supported
215                         throw new IllegalStateException(ex);
216                 }
217         }
218
219 }