package org.edx.mobile.authentication; import android.os.Bundle; import android.support.annotation.NonNull; import com.google.gson.Gson; import com.google.gson.JsonSyntaxException; import com.google.inject.Inject; import com.google.inject.Singleton; import org.edx.mobile.exception.AuthException; import org.edx.mobile.http.ApiConstants; import org.edx.mobile.http.HttpResponseStatusException; import org.edx.mobile.http.HttpStatus; import org.edx.mobile.model.api.FormFieldMessageBody; import org.edx.mobile.model.api.ProfileModel; import org.edx.mobile.module.analytics.ISegment; import org.edx.mobile.module.notification.NotificationDelegate; import org.edx.mobile.module.prefs.LoginPrefs; import org.edx.mobile.util.Config; import org.edx.mobile.util.observer.BasicObservable; import org.edx.mobile.util.observer.Observable; import java.io.IOException; import java.net.HttpURLConnection; import java.util.HashMap; import java.util.Map; import okhttp3.ResponseBody; import retrofit2.Response; @Singleton public class LoginAPI { @NonNull private final LoginService loginService; @NonNull private final Config config; @NonNull private final LoginPrefs loginPrefs; @NonNull private final ISegment segment; @NonNull private final NotificationDelegate notificationDelegate; @NonNull private final BasicObservable<LogInEvent> logInEvents = new BasicObservable<>(); @NonNull private final Gson gson; @Inject public LoginAPI(@NonNull LoginService loginService, @NonNull Config config, @NonNull LoginPrefs loginPrefs, @NonNull ISegment segment, @NonNull NotificationDelegate notificationDelegate, @NonNull Gson gson) { this.loginService = loginService; this.config = config; this.loginPrefs = loginPrefs; this.segment = segment; this.notificationDelegate = notificationDelegate; this.gson = gson; } @NonNull public Response<AuthResponse> getAccessToken(@NonNull String username, @NonNull String password) throws IOException { String grantType = "password"; String clientID = config.getOAuthClientId(); return loginService.getAccessToken(grantType, clientID, username, password).execute(); } @NonNull public AuthResponse logInUsingEmail(@NonNull String email, @NonNull String password) throws Exception { final Response<AuthResponse> response = getAccessToken(email, password); if (!response.isSuccessful()) { throw new AuthException(response.message()); } final AuthResponse data = response.body(); if (!data.isSuccess()) { throw new AuthException(data.error); } finishLogIn(data, LoginPrefs.AuthBackend.PASSWORD, email.trim()); return data; } @NonNull public AuthResponse logInUsingFacebook(String accessToken) throws Exception { return finishSocialLogIn(accessToken, LoginPrefs.AuthBackend.FACEBOOK); } @NonNull public AuthResponse logInUsingGoogle(String accessToken) throws Exception { return finishSocialLogIn(accessToken, LoginPrefs.AuthBackend.GOOGLE); } @NonNull private AuthResponse finishSocialLogIn(@NonNull String accessToken, @NonNull LoginPrefs.AuthBackend authBackend) throws Exception { final String backend = ApiConstants.getOAuthGroupIdForAuthBackend(authBackend); final Response<AuthResponse> response = loginService.exchangeAccessToken(accessToken, config.getOAuthClientId(), backend).execute(); if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) { // TODO: Introduce a more explicit error code to indicate that an account is not linked. throw new AccountNotLinkedException(); } if (!response.isSuccessful()) { throw new HttpResponseStatusException(response.code()); } final AuthResponse data = response.body(); if (data.error != null && data.error.equals(Integer.toString(HttpURLConnection.HTTP_UNAUTHORIZED))) { throw new AccountNotLinkedException(); } finishLogIn(data, authBackend, ""); return data; } private void finishLogIn(@NonNull AuthResponse response, @NonNull LoginPrefs.AuthBackend authBackend, @NonNull String usernameUsedToLogIn) throws Exception { loginPrefs.storeAuthTokenResponse(response, authBackend); try { response.profile = getProfile(); } catch (Throwable e) { // The app doesn't properly handle the scenario that we are logged in but we don't have // a cached profile. So if we fail to fetch the profile, let's erase the stored token. // TODO: A better approach might be to fetch the profile *before* storing the token. loginPrefs.clearAuthTokenResponse(); throw e; } loginPrefs.setLastAuthenticatedEmail(usernameUsedToLogIn); segment.identifyUser( response.profile.id.toString(), response.profile.email, usernameUsedToLogIn); final String backendKey = loginPrefs.getAuthBackendKeyForSegment(); if (backendKey != null) { segment.trackUserLogin(backendKey); } notificationDelegate.resubscribeAll(); logInEvents.sendData(new LogInEvent()); } public void logOut() { final AuthResponse currentAuth = loginPrefs.getCurrentAuth(); if (currentAuth != null && currentAuth.refresh_token != null) { loginService.revokeAccessToken(config.getOAuthClientId(), currentAuth.refresh_token, ApiConstants.TOKEN_TYPE_REFRESH); } } @NonNull public AuthResponse registerUsingEmail(@NonNull Bundle parameters) throws Exception { register(parameters); return logInUsingEmail(parameters.getString("username"), parameters.getString("password")); } @NonNull public AuthResponse registerUsingGoogle(@NonNull Bundle parameters, @NonNull String accessToken) throws Exception { register(parameters); return logInUsingGoogle(accessToken); } @NonNull public AuthResponse registerUsingFacebook(@NonNull Bundle parameters, @NonNull String accessToken) throws Exception { register(parameters); return logInUsingFacebook(accessToken); } @NonNull public Observable<LogInEvent> getLogInEvents() { return logInEvents; } @NonNull private void register(Bundle parameters) throws Exception { final Map<String, String> parameterMap = new HashMap<>(); for (String key : parameters.keySet()) { parameterMap.put(key, parameters.getString(key)); } Response<ResponseBody> response = loginService.register(parameterMap).execute(); if (!response.isSuccessful()) { final int errorCode = response.code(); final String errorBody = response.errorBody().string(); if ((errorCode == HttpStatus.BAD_REQUEST || errorCode == HttpStatus.CONFLICT) && !android.text.TextUtils.isEmpty(errorBody)) { try { final FormFieldMessageBody body = gson.fromJson(errorBody, FormFieldMessageBody.class); if (body != null && body.size() > 0) { throw new RegistrationException(body); } } catch (JsonSyntaxException ex) { // Looks like the response does not contain form validation errors. } } throw new HttpResponseStatusException(errorCode); } } @NonNull public ProfileModel getProfile() throws Exception { Response<ProfileModel> response = loginService.getProfile().execute(); if (!response.isSuccessful()) { throw new HttpResponseStatusException(response.code()); } ProfileModel data = response.body(); loginPrefs.storeUserProfile(data); return data; } public static class AccountNotLinkedException extends Exception { } public static class RegistrationException extends Exception { @NonNull private final FormFieldMessageBody formErrorBody; public RegistrationException(@NonNull FormFieldMessageBody formErrorBody) { this.formErrorBody = formErrorBody; } @NonNull public FormFieldMessageBody getFormErrorBody() { return formErrorBody; } } }