diff --git a/WordPress/Classes/Services/AccountService+SocialService.swift b/WordPress/Classes/Services/AccountService+SocialService.swift index f29d41e9c18b..ca1fd8eeb5b4 100644 --- a/WordPress/Classes/Services/AccountService+SocialService.swift +++ b/WordPress/Classes/Services/AccountService+SocialService.swift @@ -15,7 +15,7 @@ extension AccountService { let remote = AccountServiceRemoteREST(wordPressComRestApi: api) else { fatalError("Failed to initialize a valid remote via the default WordPress.com account.") } - remote.connectToSocialService(service, serviceIDToken: token, success: success, failure: failure) + remote.connectToSocialService(service, serviceIDToken: token, oAuthClientID: ApiCredentials.client(), oAuthClientSecret: ApiCredentials.secret(), success: success, failure: failure) } /// Disconnect an account a social service via an ID token. @@ -28,7 +28,7 @@ extension AccountService { let remote = AccountServiceRemoteREST(wordPressComRestApi: api) else { fatalError("Failed to initialize a valid remote via the default WordPress.com account.") } - remote.disconnectFromSocialService(service, success: success, failure: failure) + remote.disconnectFromSocialService(service, oAuthClientID: ApiCredentials.client(), oAuthClientSecret: ApiCredentials.secret(), success: success, failure: failure) } } diff --git a/WordPress/Classes/Services/LoginFacade.h b/WordPress/Classes/Services/LoginFacade.h index 61d0ec6bb3d3..6e52a79190ec 100644 --- a/WordPress/Classes/Services/LoginFacade.h +++ b/WordPress/Classes/Services/LoginFacade.h @@ -44,7 +44,11 @@ */ - (void)requestOneTimeCodeWithLoginFields:(LoginFields *)loginFields; - +/** + * Social login via google. + * + * @param googleIDToken A Google id_token. + */ - (void)loginToWordPressDotComWithGoogleIDToken:(NSString *)googleIDToken; /** @@ -134,5 +138,14 @@ */ - (void)finishedLoginWithGoogleIDToken:(NSString *)googleIDToken authToken:(NSString *)authToken; + +/** + * Lets the delegate know that a social login attempt found a matching user, but + * their account has not been connected to the social service previously. + * + * @param email The email address that was matched. + */ +- (void)existingUserNeedsConnection:(NSString *)email; + @end diff --git a/WordPress/Classes/Services/LoginFacade.m b/WordPress/Classes/Services/LoginFacade.m index 3c20c3c87c0b..7cbb9ef88e88 100644 --- a/WordPress/Classes/Services/LoginFacade.m +++ b/WordPress/Classes/Services/LoginFacade.m @@ -75,6 +75,10 @@ - (void)loginToWordPressDotComWithGoogleIDToken:(NSString *)googleIDToken if ([self.delegate respondsToSelector:@selector(needsMultifactorCodeForUserID:andNonceInfo:)]) { [self.delegate needsMultifactorCodeForUserID:userID andNonceInfo:nonceInfo]; } + } existingUserNeedsConnection: ^(NSString *email) { + if ([self.delegate respondsToSelector:@selector(existingUserNeedsConnection:)]) { + [self.delegate existingUserNeedsConnection: email]; + } } failure:^(NSError *error) { [WPAppAnalytics track:WPAnalyticsStatLoginFailed error:error]; if ([self.delegate respondsToSelector:@selector(displayRemoteError:)]) { diff --git a/WordPress/Classes/Services/WordPressComOAuthClientFacade.h b/WordPress/Classes/Services/WordPressComOAuthClientFacade.h index f8565cd9a2aa..ab69c771f922 100644 --- a/WordPress/Classes/Services/WordPressComOAuthClientFacade.h +++ b/WordPress/Classes/Services/WordPressComOAuthClientFacade.h @@ -19,6 +19,7 @@ - (void)authenticateWithGoogleIDToken:(NSString *)token success:(void (^)(NSString *authToken))success needsMultiFactor:(void (^)(NSInteger userID, SocialLogin2FANonceInfo *nonceInfo))needsMultifactor + existingUserNeedsConnection:(void (^)(NSString *email))existingUserNeedsConnection failure:(void (^)(NSError *error))failure; - (void)authenticateSocialLoginUser:(NSInteger)userID diff --git a/WordPress/Classes/Services/WordPressComOAuthClientFacade.m b/WordPress/Classes/Services/WordPressComOAuthClientFacade.m index 7e61e810999c..1d56fe46d5f4 100644 --- a/WordPress/Classes/Services/WordPressComOAuthClientFacade.m +++ b/WordPress/Classes/Services/WordPressComOAuthClientFacade.m @@ -36,10 +36,11 @@ - (void)requestOneTimeCodeWithUsername:(NSString *)username - (void)authenticateWithGoogleIDToken:(NSString *)token success:(void (^)(NSString *authToken))success needsMultiFactor:(void (^)(NSInteger userID, SocialLogin2FANonceInfo *nonceInfo))needsMultifactor + existingUserNeedsConnection:(void (^)(NSString *email))existingUserNeedsConnection failure:(void (^)(NSError *error))failure { WordPressComOAuthClient *client = [WordPressComOAuthClient clientWithClientID:ApiCredentials.client secret:ApiCredentials.secret]; - [client authenticateWithIDToken:token success:success needsMultifactor:needsMultifactor failure:failure]; + [client authenticateWithIDToken:token success:success needsMultifactor:needsMultifactor existingUserNeedsConnection:existingUserNeedsConnection failure:failure]; } - (void)authenticateSocialLoginUser:(NSInteger)userID diff --git a/WordPress/Classes/ViewRelated/NUX/LoginEmailViewController.swift b/WordPress/Classes/ViewRelated/NUX/LoginEmailViewController.swift index 1c9b7b5f3652..90f9b388a3bf 100644 --- a/WordPress/Classes/ViewRelated/NUX/LoginEmailViewController.swift +++ b/WordPress/Classes/ViewRelated/NUX/LoginEmailViewController.swift @@ -160,6 +160,10 @@ class LoginEmailViewController: LoginViewController, SigninKeyboardResponder { awaitingGoogle = true GIDSignIn.sharedInstance().disconnect() + // Flag this as a social sign in. + loginFields.meta.socialService = SocialServiceName.google + + // Configure all the things and sign in. GIDSignIn.sharedInstance().delegate = self GIDSignIn.sharedInstance().uiDelegate = self GIDSignIn.sharedInstance().clientID = ApiCredentials.googleLoginClientId() @@ -282,9 +286,11 @@ class LoginEmailViewController: LoginViewController, SigninKeyboardResponder { /// Validates what is entered in the various form fields and, if valid, - /// proceeds with the submit action. + /// proceeds with the submit action. Empties loginFields.meta.socialService as + /// social signin does not require form validation. /// func validateForm() { + loginFields.meta.socialService = nil displayError(message: "") guard EmailFormatValidator.validate(string: loginFields.username) else { assertionFailure("Form should not be submitted unless there is a valid looking email entered.") @@ -424,6 +430,18 @@ extension LoginEmailViewController { GIDSignIn.sharedInstance().disconnect() } + + func existingUserNeedsConnection(_ email: String!) { + // Disconnect now that we're done with Google. + GIDSignIn.sharedInstance().disconnect() + + loginFields.username = email + loginFields.emailAddress = email + + performSegue(withIdentifier: NUXAbstractViewController.SegueIdentifier.showWPComLogin, sender: self) + } + + func needsMultifactorCode(forUserID userID: Int, andNonceInfo nonceInfo: SocialLogin2FANonceInfo!) { // TODO: to be implemented. } @@ -439,9 +457,10 @@ extension LoginEmailViewController: GIDSignInDelegate { return } - // Store the email address. + // Store the email address and token. loginFields.emailAddress = email loginFields.username = email + loginFields.meta.socialServiceIDToken = token configureViewLoading(true) diff --git a/WordPress/Classes/ViewRelated/NUX/LoginFields.swift b/WordPress/Classes/ViewRelated/NUX/LoginFields.swift index a25434ec9774..84fa9eb98f14 100644 --- a/WordPress/Classes/ViewRelated/NUX/LoginFields.swift +++ b/WordPress/Classes/ViewRelated/NUX/LoginFields.swift @@ -79,4 +79,9 @@ class LoginFieldsMeta: NSObject { /// Flags whether a 2fa challenge had to be satisfied before a log in could be complete. /// Included in analytics after a successful login. var requiredMultifactor = false // A 2fa prompt was needed. + + /// Identifies a social login and the service used. + var socialService: SocialServiceName? + + var socialServiceIDToken: String? } diff --git a/WordPress/Classes/ViewRelated/NUX/LoginViewController.swift b/WordPress/Classes/ViewRelated/NUX/LoginViewController.swift index ded478062b88..79d7219130bd 100644 --- a/WordPress/Classes/ViewRelated/NUX/LoginViewController.swift +++ b/WordPress/Classes/ViewRelated/NUX/LoginViewController.swift @@ -183,6 +183,17 @@ extension LoginViewController: SigninWPComSyncHandler, LoginFacadeDelegate { func finishedLogin(withUsername username: String!, authToken: String!, requiredMultifactorCode: Bool) { syncWPCom(username, authToken: authToken, requiredMultifactor: requiredMultifactorCode) + guard let service = loginFields.meta.socialService, service == SocialServiceName.google, + let token = loginFields.meta.socialServiceIDToken else { + return + } + + let accountService = AccountService(managedObjectContext: ContextManager.sharedInstance().mainContext) + accountService.connectToSocialService(service, serviceIDToken: token, success: { + // noop + }, failure: { error in + DDLogError(error.description) + }) } func displayRemoteError(_ error: Error!) { diff --git a/WordPress/Classes/ViewRelated/NUX/LoginWPComViewController.swift b/WordPress/Classes/ViewRelated/NUX/LoginWPComViewController.swift index a14848acac6a..7a02014e395d 100644 --- a/WordPress/Classes/ViewRelated/NUX/LoginWPComViewController.swift +++ b/WordPress/Classes/ViewRelated/NUX/LoginWPComViewController.swift @@ -120,6 +120,12 @@ class LoginWPComViewController: LoginViewController, SigninKeyboardResponder { } func localizeControls() { + if let service = loginFields.meta.socialService, service == SocialServiceName.google { + instructionLabel?.text = NSLocalizedString("To proceed with this Google account, please first log in with your WordPress.com password. This will only be asked once.", comment: "") + } else { + instructionLabel?.text = NSLocalizedString("Enter the password for your WordPress.com account.", comment: "Instructional text shown when requesting the user's password for login.") + } + passwordField?.placeholder = NSLocalizedString("Password", comment: "Password placeholder") passwordField?.accessibilityIdentifier = "Password" diff --git a/WordPressKit/WordPressKit/AccountServiceRemoteREST+SocialService.swift b/WordPressKit/WordPressKit/AccountServiceRemoteREST+SocialService.swift index 3a21b4a7d04d..af5f01f74440 100644 --- a/WordPressKit/WordPressKit/AccountServiceRemoteREST+SocialService.swift +++ b/WordPressKit/WordPressKit/AccountServiceRemoteREST+SocialService.swift @@ -12,16 +12,20 @@ extension AccountServiceRemoteREST { /// - Parameters: /// - service The name of the social service. /// - token The OpenID Connect (JWT) ID token identifying the user on the social service. + /// - oAuthClientID The WPCOM REST API client ID. + /// - oAuthClientSecret The WPCOM REST API client secret. /// - success The block that will be executed on success. /// - failure The block that will be executed on failure. - public func connectToSocialService(_ service: SocialServiceName, serviceIDToken token: String, success:@escaping (() -> Void), failure:@escaping ((NSError) -> Void)) { + public func connectToSocialService(_ service: SocialServiceName, serviceIDToken token: String, oAuthClientID: String, oAuthClientSecret: String, success:@escaping (() -> Void), failure:@escaping ((NSError) -> Void)) { guard let path = self.path(forEndpoint: "me/social-login/connect", with: .version_1_1) else { // This should never fail but if it does we don't want to ignore the problem. fatalError("There was a problem creating a valid path for the supplied endpoint and REST API version.") } let params = [ + "client_id": oAuthClientID, + "client_secret": oAuthClientSecret, "service": service.rawValue, - "id_token": token + "id_token": token, ] as [String: AnyObject] wordPressComRestApi.POST(path, parameters: params, success: { (responseObject, httpResponse) in success() @@ -34,14 +38,18 @@ extension AccountServiceRemoteREST { /// /// - Parameters: /// - service The name of the social service. + /// - oAuthClientID The WPCOM REST API client ID. + /// - oAuthClientSecret The WPCOM REST API client secret. /// - success The block that will be executed on success. /// - failure The block that will be executed on failure. - public func disconnectFromSocialService(_ service: SocialServiceName, success:@escaping(() -> Void), failure:@escaping((NSError) -> Void)) { + public func disconnectFromSocialService(_ service: SocialServiceName, oAuthClientID: String, oAuthClientSecret: String, success:@escaping(() -> Void), failure:@escaping((NSError) -> Void)) { guard let path = self.path(forEndpoint: "me/social-login/disconnect", with: .version_1_1) else { // This should never fail but if it does we don't want to ignore the problem. fatalError("There was a problem creating a valid path for the supplied endpoint and REST API version.") } let params = [ + "client_id": oAuthClientID, + "client_secret": oAuthClientSecret, "service": service.rawValue, ] as [String: AnyObject] wordPressComRestApi.POST(path, parameters: params, success: { (responseObject, httpResponse) in diff --git a/WordPressKit/WordPressKit/WordPressComOAuthClient.swift b/WordPressKit/WordPressKit/WordPressComOAuthClient.swift index 65a31bc7224e..fa4c5ea546f9 100644 --- a/WordPressKit/WordPressKit/WordPressComOAuthClient.swift +++ b/WordPressKit/WordPressKit/WordPressComOAuthClient.swift @@ -8,6 +8,7 @@ import CocoaLumberjack case invalidRequest case needsMultifactorCode case invalidOneTimePassword + case socialLoginExistingUserUnconnected } /// `WordPressComOAuthClient` encapsulates the pattern of authenticating against WordPress.com OAuth2 service. @@ -17,6 +18,7 @@ import CocoaLumberjack /// public final class WordPressComOAuthClient: NSObject { + public static let WordPressComOAuthErrorResponseObjectKey = "WordPressComOAuthErrorResponseObjectKey" public static let WordPressComOAuthErrorDomain = "WordPressComOAuthError" public static let WordPressComOAuthBaseUrl = "https://public-api.wordpress.com/oauth2" public static let WordPressComSocialLoginUrl = "https://wordpress.com/wp-login.php?action=social-login-endpoint&version=1.0" @@ -149,6 +151,7 @@ public final class WordPressComOAuthClient: NSObject { public func authenticateWithIDToken(_ token: String, success: @escaping (_ authToken: String?) -> Void, needsMultifactor: @escaping (_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo) -> Void, + existingUserNeedsConnection: @escaping (_ email: String) -> Void, failure: @escaping (_ error: NSError) -> Void ) { let parameters = [ "client_id": clientID, @@ -190,7 +193,22 @@ public final class WordPressComOAuthClient: NSObject { needsMultifactor(userID, nonceInfo) }, failure: { (task, error) in - failure(error as NSError) + let err = error as NSError + + // Inspect the error and handle the case of an existing user. + if err.code == WordPressComOAuthError.socialLoginExistingUserUnconnected.rawValue && + err.domain == WordPressComOAuthClient.WordPressComOAuthErrorDomain { + // Get the responseObject from the userInfo dict. + // Extract the email address for the callback. + if let responseDict = err.userInfo[WordPressComOAuthClient.WordPressComOAuthErrorResponseObjectKey] as? [String: AnyObject], + let data = responseDict["data"] as? [String: AnyObject], + let email = data["email"] as? String { + + existingUserNeedsConnection(email) + return + } + } + failure(err) } ) } @@ -308,37 +326,85 @@ final class WordPressComOAuthResponseSerializer: AFJSONResponseSerializer { super.init(coder: aDecoder) } + + /// Possible 400 errors: + /// - invalid_client: client_id is missing or wrong, it shouldn't happen + /// - unsupported_grant_type: client_id doesn't support password grants + /// - invalid_request: A required field is missing/malformed + /// - invalid_request: Authentication failed + /// - needs_2fa: Multifactor Authentication code is required + /// - user_exists: Returned by the social login endpoint if a wpcom user is found, but not connected to a social service. + /// + let errorsMap = [ + "invalid_client": WordPressComOAuthError.invalidClient, + "unsupported_grant_type": WordPressComOAuthError.unsupportedGrantType, + "invalid_request": WordPressComOAuthError.invalidRequest, + "needs_2fa": WordPressComOAuthError.needsMultifactorCode, + "invalid_otp": WordPressComOAuthError.invalidOneTimePassword, + "user_exists": WordPressComOAuthError.socialLoginExistingUserUnconnected, + ] + + + /// Overridden to provide custom error handling. Some HTTP requests include + /// a response body even in a failure scenario. Since AFNetworking does not + /// pass a responseObject (if any) to a failure block this method ensures + /// it is available via an error's userInfo dictionary. + /// + /// - Parameters: + /// - response: The URL response. + /// - data: Data returned from the request. + /// - error: A pointer to an error (if any). + /// - Returns: The response object or nil. override func responseObject(for response: URLResponse?, data: Data?, error: NSErrorPointer) -> Any? { let responseObject = super.responseObject(for: response, data: data, error: error) - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 400, - let responseDictionary = responseObject as? [String: AnyObject], - let errorCode = responseDictionary["error"] as? String, - let errorDescription = responseDictionary["error_description"] as? String - else { - return responseObject as AnyObject? + guard let httpResponse = response as? HTTPURLResponse else { + return responseObject } - /// Possible errors: - /// - invalid_client: client_id is missing or wrong, it shouldn't happen - /// - unsupported_grant_type: client_id doesn't support password grants - /// - invalid_request: A required field is missing/malformed - /// - invalid_request: Authentication failed - /// - needs_2fa: Multifactor Authentication code is required - /// - let errorsMap = [ - "invalid_client": WordPressComOAuthError.invalidClient, - "unsupported_grant_type": WordPressComOAuthError.unsupportedGrantType, - "invalid_request": WordPressComOAuthError.invalidRequest, - "needs_2fa": WordPressComOAuthError.needsMultifactorCode, - "invalid_otp" : WordPressComOAuthError.invalidOneTimePassword - ] + // Handle known 400 errors. + if httpResponse.statusCode == 400 { + // REST API Error format + if let responseDictionary = responseObject as? [String: AnyObject], + let errorCode = responseDictionary["error"] as? String, + let errorDescription = responseDictionary["error_description"] as? String { - let mappedCode = errorsMap[errorCode]?.rawValue ?? WordPressComOAuthError.unknown.rawValue + error?.pointee = errorFor(errorCode: errorCode, errorDescription: errorDescription, responseObject: responseObject) + } + + } else if httpResponse.statusCode == 409 { + // Social login user-exists error + if let responseDict = responseObject as? [String: AnyObject], + let data = responseDict["data"] as? [String: AnyObject], + let errors = data["errors"] as? NSArray, + let err = errors[0] as? [String: AnyObject], + let errorCode = err["code"] as? String, + let errorDescription = err["message"] as? String { + + error?.pointee = errorFor(errorCode: errorCode, errorDescription: errorDescription, responseObject: responseObject) + } + } - error?.pointee = NSError(domain: WordPressComOAuthClient.WordPressComOAuthErrorDomain, - code: mappedCode, - userInfo: [NSLocalizedDescriptionKey: errorDescription]) return responseObject as AnyObject? } + + + /// Creates an NSError from the supplied arguements. The response object is + /// added to the error's userInfo dictionary. + /// + /// - Parameters: + /// - errorCode: A string representing the error code. This is not the same as an HTTP status code. + /// - errorDescription: A description of the error. + /// - responseObject: The responseObject (if any) that was passed with the error. + /// - Returns: An NSError. + func errorFor(errorCode: String, errorDescription: String, responseObject: Any?) -> NSError { + var userInfo:[String: AnyObject] = [NSLocalizedDescriptionKey: errorDescription as AnyObject] + if let responseObject = responseObject { + userInfo[WordPressComOAuthClient.WordPressComOAuthErrorResponseObjectKey] = responseObject as AnyObject + } + let mappedCode = errorsMap[errorCode]?.rawValue ?? WordPressComOAuthError.unknown.rawValue + return NSError(domain: WordPressComOAuthClient.WordPressComOAuthErrorDomain, + code: mappedCode, + userInfo: userInfo) + } }