001/**
002 * Copyright 2014 Tampere University of Technology, Pori Department
003 * 
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 * 
008 *   http://www.apache.org/licenses/LICENSE-2.0
009 * 
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package service.tut.pori.users.facebook;
017
018import java.io.IOException;
019
020import javax.servlet.http.HttpSession;
021
022import org.apache.commons.lang3.RandomStringUtils;
023import org.apache.commons.lang3.StringUtils;
024import org.apache.commons.lang3.tuple.Pair;
025import org.apache.http.client.methods.HttpGet;
026import org.apache.http.impl.client.BasicResponseHandler;
027import org.apache.http.impl.client.CloseableHttpClient;
028import org.apache.http.impl.client.HttpClients;
029import org.apache.log4j.Logger;
030import org.springframework.context.ApplicationListener;
031
032import service.tut.pori.users.UserCore;
033import service.tut.pori.users.UserCore.Registration;
034import service.tut.pori.users.UserCore.RegistrationStatus;
035import service.tut.pori.users.UserServiceEvent;
036import service.tut.pori.users.google.OAuth2Token;
037import core.tut.pori.context.ServiceInitializer;
038import core.tut.pori.http.RedirectResponse;
039import core.tut.pori.http.Response;
040import core.tut.pori.http.Response.Status;
041import core.tut.pori.users.ExternalAccountConnection;
042import core.tut.pori.users.UserEvent;
043import core.tut.pori.users.ExternalAccountConnection.UserServiceType;
044import core.tut.pori.users.UserEvent.EventType;
045import core.tut.pori.users.UserIdentity;
046
047/**
048 * Facebook User Service Core methods.
049 * 
050 * This implementation follows:
051 * 
052 * refreshing tokens:
053 * http://developers.facebook.com/docs/facebook-login/access-tokens/#extending
054 * http://stackoverflow.com/questions/16563692/refresh-token-and-access-token-in-facebook-api
055 * 
056 * http://developers.facebook.com/docs/facebook-login/login-flow-for-web-no-jssdk/
057 * 
058 * This class emits events of type {@link core.tut.pori.users.UserEvent} for user account modifications with one of the listed {@link core.tut.pori.users.UserEvent.EventType} :
059 * <ul>
060 *  <li>{@link core.tut.pori.users.UserEvent.EventType#USER_AUTHORIZATION_GIVEN} for new user account authorizations.</li>
061 *  <li>{@link core.tut.pori.users.UserEvent.EventType#USER_AUTHORIZATION_REVOKED} for removed user account authorizations.</li>
062 * </ul>
063 */
064public final class FacebookUserCore {
065  private static final Logger LOGGER = Logger.getLogger(FacebookUserCore.class);
066  private static final char DELIMITER_PARAMETER_VALUE = '=';
067  private static final char DELIMITER_PARAMETERS = '&';
068  private static final String PARAMETER_ACCESS_TOKEN = Definitions.PARAMETER_OAUTH2_ACCESS_TOKEN; // facebook specific body-parameter
069  private static final String PARAMETER_EXPIRES = "expires"; // facebook specific body parameter
070  private static final String PARAMETER_VALUE_REFRESH_GRANT_TYPE = "fb_exchange_token";
071  private static final String PARAMETER_VALUE_RESPONSE_TYPE = "code";
072  
073
074  /**
075   * 
076   */
077  private FacebookUserCore(){
078    // nothing needed
079  }
080
081  /**
082   * 
083   * @param authorizationCode
084   * @param errorCode
085   * @param nonce
086   * @return response
087   */
088  public static Response processOAuth2Callback(String authorizationCode, String errorCode, String nonce) {
089    if(StringUtils.isBlank(nonce)){
090      LOGGER.debug("nonce is missing.");
091      return new Response(Status.BAD_REQUEST);
092    }
093
094    //Try to split nonce for a redirection URL
095    Pair<String, String> nonceAndUrl = UserCore.getNonceAndRedirectUri(nonce);
096    String redirectUri = null;
097    if(nonceAndUrl != null){
098      nonce = nonceAndUrl.getLeft();
099      redirectUri = nonceAndUrl.getRight();
100    }else{
101      redirectUri = null;
102    }
103    
104    FacebookUserDAO dao = ServiceInitializer.getDAOHandler().getSQLDAO(FacebookUserDAO.class);
105
106    if(!StringUtils.isEmpty(errorCode)){
107      dao.removeNonce(nonce); // in any case, do not allow to use this nonce again
108      LOGGER.debug("Received callback request with errorCode: "+errorCode);
109      if(StringUtils.isEmpty(redirectUri)){
110        return new Response(Status.OK, errorCode);
111      }else if(redirectUri.contains("?")){
112        return new RedirectResponse(redirectUri+"&error="+errorCode);
113      }else{
114        return new RedirectResponse(redirectUri+"?error="+errorCode);
115      }
116    }
117
118    UserIdentity userId = dao.getUser(nonce);
119    if(userId == null){ // the nonce do not exist or has expired
120      LOGGER.debug("Received callback request with expired or invalid nonce: "+nonce);
121      return new Response(Status.BAD_REQUEST);
122    }
123
124    dao.removeNonce(nonce); // in any case, do not allow to use this nonce again
125
126    if(StringUtils.isBlank(authorizationCode)){
127      LOGGER.debug("no authorization code.");
128      return new Response(Status.BAD_REQUEST);
129    }
130
131    try (CloseableHttpClient client = HttpClients.createDefault()) {
132      FacebookProperties fp = ServiceInitializer.getPropertyHandler().getSystemProperties(FacebookProperties.class);
133      StringBuilder uri = new StringBuilder(fp.getoAuth2TokenUri());
134      uri.append(Definitions.METHOD_FACEBOOK_ACCESS_TOKEN+"?"+Definitions.PARAMETER_OAUTH2_CLIENT_ID+"=");
135      uri.append(fp.getApplicationId());
136
137      uri.append("&"+Definitions.PARAMETER_OAUTH2_REDIRECT_URI+"=");
138      uri.append(fp.getEncodedOAuth2RedirectUri());
139
140      uri.append("&"+Definitions.PARAMETER_OAUTH2_CLIENT_SECRET+"=");
141      uri.append(fp.getSharedKey());
142
143      uri.append("&"+Definitions.PARAMETER_OAUTH2_AUTHORIZATION_CODE+"=");
144      uri.append(authorizationCode);
145
146      OAuth2Token newToken = fromResponse(client.execute(new HttpGet(uri.toString()), new BasicResponseHandler()));
147      if(newToken != null && newToken.isValid()){
148        FacebookCredential fc = getCredential(newToken.getAccessToken());
149        if(fc == null){
150          LOGGER.error("Failed to resolve credentials for the new token.");
151          return new Response(Status.INTERNAL_SERVER_ERROR);
152        }   
153        
154        if(!dao.setToken(fc.getId(), newToken, userId)){
155          LOGGER.warn("Failed to set new token.");
156          return new Response(Status.BAD_REQUEST);
157        }
158        
159        ServiceInitializer.getEventHandler().publishEvent(new UserEvent(FacebookUserCore.class, userId, EventType.USER_AUTHORIZATION_GIVEN));
160      }else{
161        LOGGER.debug("Did not receive a valid token.");
162      }
163    } catch (IOException ex) {
164      LOGGER.error(ex, ex);
165    } 
166    
167    if(StringUtils.isEmpty(redirectUri)){
168      return new Response();
169    }else{
170      return new RedirectResponse(redirectUri);
171    }
172  }
173
174  /**
175   * parses a facebook token response of format: access_token=ACCESS_TOKEN&expires=SECONDS_TILL_EXPIRATION
176   * 
177   * @param response
178   * @return the token or null if invalid string
179   */
180  private static OAuth2Token fromResponse(String response){
181    String[] params = StringUtils.split(response, DELIMITER_PARAMETERS);
182    if(params == null || params.length  < 2){
183      LOGGER.debug("Invalid response: "+response);
184      return null;
185    }
186    String accessToken = null;
187    Long expiresIn = null;
188    try{
189      for(int i=0;i<params.length;++i){
190        String[] parts = StringUtils.split(params[i],DELIMITER_PARAMETER_VALUE);
191        if(parts.length != 2){
192          LOGGER.debug("Ignored invalid parameter: "+parts[i]);
193        }else if(PARAMETER_EXPIRES.equalsIgnoreCase(parts[0])){
194          expiresIn = Long.valueOf(parts[1]);
195        }else if(PARAMETER_ACCESS_TOKEN.equalsIgnoreCase(parts[0])){
196          accessToken = parts[1];
197        }
198      }
199    } catch (NumberFormatException ex){
200      LOGGER.error(ex, ex);
201    }
202
203    if(expiresIn == null || accessToken == null){
204      LOGGER.debug("Did not receive valid token. "+PARAMETER_ACCESS_TOKEN+": "+accessToken+", "+PARAMETER_EXPIRES+": "+expiresIn);
205      return null;
206    }
207
208    OAuth2Token token = new OAuth2Token();
209    token.setAccessToken(accessToken);
210    token.setExpiresIn(expiresIn);
211    return token;
212  }
213
214  /**
215   * 
216   * @param authorizedUser valid user id
217   * @param redirectUri 
218   * @return redirection response
219   */
220  public static Response createAuthorizationRedirection(UserIdentity authorizedUser, String redirectUri) {
221    FacebookProperties fp = ServiceInitializer.getPropertyHandler().getSystemProperties(FacebookProperties.class);
222    StringBuilder uri = new StringBuilder(fp.getoAuth2AuthUri());
223
224    uri.append(Definitions.METHOD_FACEBOOK_AUTH+"?"+Definitions.PARAMETER_OAUTH2_SCOPE+"=");
225    uri.append(fp.getoAuth2Scope());
226
227    uri.append("&"+Definitions.PARAMETER_OAUTH2_STATE+"=");
228    uri.append(UserCore.urlEncodedCombinedNonce(ServiceInitializer.getDAOHandler().getSQLDAO(FacebookUserDAO.class).generateNonce(authorizedUser), redirectUri));
229
230    uri.append("&"+Definitions.PARAMETER_OAUTH2_REDIRECT_URI+"=");
231    uri.append(fp.getEncodedOAuth2RedirectUri());
232
233    uri.append("&"+Definitions.PARAMETER_OAUTH2_CLIENT_ID+"=");
234    uri.append(fp.getApplicationId());
235
236    uri.append("&"+Definitions.PARAMETER_OAUTH2_RESPONSE_TYPE+"="+PARAMETER_VALUE_RESPONSE_TYPE);
237
238    return new RedirectResponse(uri.toString());
239  }
240  
241  /**
242   * 
243   * @param authenticatedUser
244   * @return response
245   */
246  public static Response removeAuthorization(UserIdentity authenticatedUser) {
247    ServiceInitializer.getDAOHandler().getSQLDAO(FacebookUserDAO.class).removeToken(authenticatedUser); // simply remove the token, as it is not possible to revoke facebook tokens, they should auto-expire after a certain time
248    
249    ServiceInitializer.getEventHandler().publishEvent(new UserEvent(FacebookUserCore.class, authenticatedUser, EventType.USER_AUTHORIZATION_REVOKED));  // send revoked event, this should trigger clean up on all relevant services
250    return null;
251  }
252
253  /**
254   * Retrieves the current token for the user if one is available
255   * 
256   * Note: if the token expires, you should use this method to retrieve a new one, refreshing the token
257   * manually may cause race condition with other services using the tokens (there can be only one active
258   * and valid access token at any time)
259   * 
260   * @param authorizedUser
261   * @return token for the given user or null if not found
262   */
263  public static OAuth2Token getToken(UserIdentity authorizedUser){
264    FacebookUserDAO dao = ServiceInitializer.getDAOHandler().getSQLDAO(FacebookUserDAO.class);
265    OAuth2Token token = dao.getToken(authorizedUser);
266    if(token == null){
267      return null;
268    }
269    
270    String facebookUserId = dao.getFacebookUserId(authorizedUser);
271    if(facebookUserId == null){
272      LOGGER.warn("Failed to resolve facebook user id.");
273      return null;
274    }
275
276    if(token.expiresIn(ServiceInitializer.getPropertyHandler().getSystemProperties(FacebookProperties.class).getTokenAutorefresh())){ // Facebook does not support refreshing tokens, so if a token has expired there really isn't much we can do
277      token = refreshToken(token);
278      if(token == null){
279        dao.removeToken(authorizedUser);  // remove the invalid token from the database
280      }else{
281        dao.setToken(facebookUserId, token, authorizedUser);
282      }
283    }
284    return token;
285  }
286
287  /**
288   * 
289   * @param token
290   * @return the refreshed token or null on failure, Note: the returned token may not be the same object as the passed object
291   */
292  private static OAuth2Token refreshToken(OAuth2Token token){
293    if(token.isExpired()){
294      LOGGER.debug("Expired or invalid token given.");
295      return null;
296    }
297    try (CloseableHttpClient client = HttpClients.createDefault()) {
298      FacebookProperties fp = ServiceInitializer.getPropertyHandler().getSystemProperties(FacebookProperties.class);
299      StringBuilder uri = new StringBuilder(fp.getoAuth2TokenUri());
300      uri.append(Definitions.METHOD_FACEBOOK_ACCESS_TOKEN+"?"+Definitions.PARAMETER_OAUTH2_GRANT_TYPE+"="+PARAMETER_VALUE_REFRESH_GRANT_TYPE);
301
302      uri.append("&"+Definitions.PARAMETER_OAUTH2_CLIENT_ID+"=");
303      uri.append(fp.getApplicationId());
304
305      uri.append("&"+Definitions.PARAMETER_OAUTH2_CLIENT_SECRET+"=");
306      uri.append(fp.getSharedKey());
307
308      uri.append("&"+Definitions.PARAMETER_FACEBOOK_EXCHANGE_TOKEN+"=");
309      uri.append(token.getAccessToken());
310
311      OAuth2Token newToken = fromResponse(client.execute(new HttpGet(uri.toString()), new BasicResponseHandler()));
312      if(newToken != null && newToken.isValid()){
313        return newToken;
314      }
315    } catch (IOException ex) {
316      LOGGER.error(ex, ex);
317    }
318    return null;
319  }
320  
321  /**
322   * 
323   * @param userId
324   * @return FacebookCredential for the requested userId or null if none available
325   */
326  public static FacebookCredential getCredential(UserIdentity userId){
327    if(!UserIdentity.isValid(userId)){
328      LOGGER.warn("Given userId was not valid.");
329      return null;
330    }
331
332    OAuth2Token token = FacebookUserCore.getToken(userId);
333    if(token == null){
334      LOGGER.debug("User does not have valid Facebook credentials.");
335      return null;
336    }
337    
338    FacebookCredential credential = getCredential(token.getAccessToken());
339    if(credential == null){
340      LOGGER.debug("Failed to resolve credentials.");
341    }else{
342      credential.setUserId(userId);
343    }
344    return credential;
345  }
346  
347  /**
348   * Helper method for retrieving credentials from facebook servers
349   * 
350   * @param accessToken
351   * @return the credential for the access token or null if not found
352   */
353  private static FacebookCredential getCredential(String accessToken){
354    FacebookCredential credential = null;
355    try(CloseableHttpClient client = HttpClients.createDefault()){
356      FacebookProperties fp = ServiceInitializer.getPropertyHandler().getSystemProperties(FacebookProperties.class);
357      credential = (OAuth2Token.getTokenGSONSerializer()).fromJson(client.execute(new HttpGet(fp.getoAuth2UserInfoUri()+"?"+Definitions.PARAMETER_OAUTH2_ACCESS_TOKEN+"="+accessToken), new BasicResponseHandler()), FacebookCredential.class);
358    } catch (IOException ex) {
359      LOGGER.error(ex, ex);
360    }
361    
362    return credential;
363  }
364
365  /**
366   * 
367   * @param session
368   * @param accessToken
369   * @return response
370   */
371  public static Response login(HttpSession session, String accessToken) {
372    FacebookCredential credential = getCredential(accessToken);
373    if(credential == null){
374      return new Response(Status.BAD_REQUEST, "Failed to process the given token.");
375    }
376    
377    UserIdentity userId = UserCore.getUserId(new ExternalAccountConnection(credential.getId(), UserServiceType.FACEBOOK));
378    if(!UserIdentity.isValid(userId)){
379      return new Response(Status.FORBIDDEN, "The given Facebook user is not registered with this service, please register before login.");
380    }
381    
382    ServiceInitializer.getSessionHandler().registerAndAuthenticate(session.getId(), userId);
383    return new Response();
384  }
385
386  /**
387   * 
388   * @param accessToken
389   * @return response
390   */
391  public static Response register(String accessToken) {
392    FacebookCredential credential = getCredential(accessToken);
393    if(credential == null){
394      return new Response(Status.BAD_REQUEST, "Failed to process the given token.");
395    }
396    
397    ExternalAccountConnection connection = new ExternalAccountConnection(credential.getId(), UserServiceType.FACEBOOK);
398    UserIdentity userId = UserCore.getUserId(connection);
399    if(userId != null){
400      return new Response(Status.BAD_REQUEST, "The user is already registered with this service.");
401    }
402    
403    Registration registration = new Registration();
404    String facebookUserId = credential.getId();
405    registration.setUsername(UserServiceType.FACEBOOK.name()+facebookUserId); // use prefix to prevent collisions with other services
406    registration.setPassword(RandomStringUtils.randomAlphanumeric(50)); // create a random password for this account
407    
408    RegistrationStatus status = UserCore.createUser(registration); // register as new user
409    if(status != RegistrationStatus.OK){
410      return new Response(Status.BAD_REQUEST, "Failed to create new user: "+status.name());
411    }
412    
413    connection.setExternalId(facebookUserId);
414    UserCore.insertExternalAccountConnection(connection, registration.getRegisteredUserId()); // link the created user to the given account
415    return new Response();
416  }
417  
418  /**
419   * Event listener for user related events.
420   * 
421   * Automatically instantiated by Spring as a bean.
422   */
423  @SuppressWarnings("unused")
424  private static class UserEventListener implements ApplicationListener<UserServiceEvent>{
425
426    @Override
427    public void onApplicationEvent(UserServiceEvent event) {
428      EventType type = event.getType();
429      if(type == EventType.USER_REMOVED || (type == EventType.USER_AUTHORIZATION_REVOKED && event.getSource().equals(UserCore.class) && UserServiceType.FACEBOOK.equals(event.getUserServiceType()))){
430        UserIdentity userId = event.getUserId();
431        LOGGER.debug("Detected event of type "+type.name()+", removing tokens for user, id: "+userId.getUserId());
432        ServiceInitializer.getDAOHandler().getSQLDAO(FacebookUserDAO.class).removeToken(userId);
433        
434        ServiceInitializer.getEventHandler().publishEvent(new UserEvent(FacebookUserCore.class, userId, EventType.USER_AUTHORIZATION_REVOKED));  // send revoked event, this should trigger clean up on all relevant services
435      }
436    }
437  } // class UserEventListener
438}