Hoy voy a explicar como facilitar que los usuarios de una app PhoneGap, desarrollada utilizando Ionic framework, puedan registrarse en la app con el login de Google+.

Voy a realizar el tutorial para probarlo en plataforma Android, pero explicaré los pasos que habría que seguir también en iOS, aunque no lo probaré en un iOS real.

Si quieres trastear con el código, al final del artículo encontrarás el enlace al repositorio de github donde está alojado.

¿Quieres aprender Ionic Framework? ¡¡Mira mi curso de ionic completamente gratuito!!

Registro de la app en el ecosistema Google

Lo primero que haré, es registrar la app en el ecosistema de Google+, ya que es requisito indispensable para utilizar la API de login de Google+.

Sigue el tutorial Registrar app en Google+ para autentificar usuarios paso a paso, y vuelve a aquí cuando lo hayas completado.

Desarrollo de la app

Doy por sentado que se tiene instalado ionic y un mínimo de conocimiento de como funciona. En caso contrario, te recomiendo leer por encima esto

Creación de la app

Me voy directo al terminal, y creo el proyecto googleLoginDemo a partir de un proyecto vacío:

$: ionic start googleLoginDemo blank

Una vez creado, entro en el proyecto:

$: cd googleLoginDemo

Añadiendo plataformas

Para añadir la plataforma Android, ejecuto en terminal:

googleLoginDemo$:ionic platform android
googleLoginDemo$:ionic build android

De forma equivalente, para iOS, tendría que ejecutar:

googleLoginDemo$:ionic platform ios
googleLoginDemo$:ionic build ios

Abrir el proyecto en Eclipse

Importo el proyecto a Eclipse con:
File > New > Project… > Android Project from Existing Code

Selecciono el directorio googleLoginDemo con mi proyecto.

DETALLE: Para que el proyecto se actualice con los cambios y pueda ejecutarlo correctamente desde Eclipse, antes de ejecutarlo tengo que ejecutar por consola: cordova prepare android, y posteriormente hacer un refresh en Eclipse.

Añadir el Package name

Anteriormente he registrado una app en Google, para autentificarme a través de Google+, y he definido un indentificador de aplicación tipo: com.enriqueoriol.googleLoginDemo.android

Ahora tengo que definir este mismo identificador en la app, y esto lo hago en la sección id del archivo en la raíz de mi directorio config.xml.

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<widget id="com.enriqueoriol.googleLoginDemo.android" ...

DETALLE: Al reemplazarlo en el config.xml, ionic actualizará automáticamente el archivo AndroidManifest.xml cada vez que lo ejecutemos. Si lo reemplazas directamente en el manifest, tu nombre se sobreescribirá y no podrás ejecutar la app.

 

Estructura de la app

Lo que voy a crear es una app con 2 vistas, gestionadas por una barra de navegación.

  • La primera vista, se cargará por defecto, y tendrá un botón de login de Google+
  • Una vez logueados nos dará paso a la segunda vista, con información del usuario, y que tendrá un botón para hacer logout

Aunque se trata de un proyecto sencillo, me gusta tener bien ordenado el código, por lo que crearé una carpeta para cada una de estas vistas, donde irá su template y su javascript.
De momento, resumo que la estructura será:

www/
index.html
app/
app.js
login/
login.html
login.js
userInfo/
userInfo.html
userInfo.js
css/
style.css
img/
lib/

app.js

El proyecto blank en el que está basado mi proyecto, ya ha generado un archivo js/app.js con la inicialización más básica. Como ves de la estructura anterior, yo lo muevo a la carpeta app que he creado, y le añado configuración de estados para definir un estado asociado a cada vista.
Queda así:

// Google Login Demo App

angular.module('googleLoginDemo', ['ionic', 'login', 'userInfo'])

.run(function($ionicPlatform) {
  $ionicPlatform.ready(function() {
    // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard
    // for form inputs)
    if(window.cordova && window.cordova.plugins.Keyboard) {
      cordova.plugins.Keyboard.hideKeyboardAccessoryBar(true);
    }
    if(window.StatusBar) {
      StatusBar.styleDefault();
    }
  });
})

.config(function($stateProvider, $urlRouterProvider){
    $urlRouterProvider.otherwise('/')

    $stateProvider
    .state('login', {
        url: '/',
        controller: 'LoginCtrl',
        templateUrl: 'app/login/login.html'
    })
    .state('userInfo', {
        url: '/userInfo',
        controller: 'UserInfoCtrl',
        templateUrl: 'app/userInfo/userInfo.html'
    });
})

A destacar:

  • Incluyo los módulos login y userInfo. Los definiré en breve, y son los que tendrán la funcionalidad de cada una de las vistas respectivamente.
  • Creo los estados login y userInfo. Los estados permiten identificar una vista y vincularla a una url, definir el controlador que se va a usar, etc.

    Más adelante veré como dirigirnos a una vista con $state.go(‘nombreEstado’);

Index.html

El proyecto blank tamibén ha generado un archivo index.html con lo básico (librerías y css requeridos). Yo voy a cambiarle el body, y reemplazarlo por un simple ion-nav-view, que es un espacio que se reemplaza por las ion-view que contendrán mis vistas. Además, también añado una barra de navegación ion-nav-bar, y un botón atrás ion-nav-back-button, para poder volver de la segunda vista a la primera.

Queda así:

<!DOCTYPE html>
<html>

  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
    <title>google Login Demo Project</title>

    <link href="lib/ionic/css/ionic.css" rel="stylesheet">
    <link href="css/style.css" rel="stylesheet">
    <script src="lib/ionic/js/ionic.bundle.js"></script>

    <!-- cordova script (this will be a 404 during development) -->
    <script src="cordova.js"></script>
    <!-- your app's js -->
    <script src="app/app.js"></script>

    <script src="app/login/login.js"></script>
    <script src="app/userInfo/userInfo.js"></script>

  </head>


  <body ng-app="googleLoginDemo">

    <ion-nav-bar class="bar-positive">
      <ion-nav-back-button class="button-clear">
      </ion-nav-back-button>
    </ion-nav-bar>

    <ion-nav-view></ion-nav-view>
  </body>


</html>

Importante: Es imprescindible que incluya, como hago arriba, los scripts JS login.js y userInfo.js donde quiero definir el comportamiento de cada vista.

Vista de login

Template

Creo un template muy sencillo, en app/login/login.js:

<ion-view title="Login">
    <ion-content>
        <h3 class="padding">Let's try Google + Login!</h3>
        <br/>
        <button class="button button-assertive button-block padding" ng-click="login()">
            Google+ login!
        </button>
        <br/>
    </ion-content>
</ion-view>

Se trata de un simple botón, que llama a la función login() al hacer click.

Módulo y controlador

Implemento la lógica (de momento vacía) en app/login/login.js:

angular.module('login', [])


.controller('LoginCtrl', function($scope, $state) {

    var initView = function(){
        //Do any expected initialization
    }

    $scope.$on('$ionicView.beforeEnter', function(){
        initView();
    });

    $scope.login = function(){
        //TODO: perform login operation
        $state.go('userInfo');
    }

})

Básicamente creo una función login en el scope, que de momento lo que hace es enviarme a la otra vista.

¿Qué aspecto tiene esto? Sencillo, pero suficiente para lo que queremos.
Ahí lo ves:

loginView

 

Vista de información

Template

Creo un template muy sencillo, en app/userInfo/userInfo.js:

<ion-view title="User Info">
    <ion-content>
        <br/>
        <div class="padding">
            <span style="font-width:strong; font-size:large"> You're logged as:</span> {{userName}}
        </div>
        <button class="button button-assertive button-block padding" ng-click="logout()">Log out!</button>
        <br/>
    </ion-content>
</ion-view>

En este caso, en la primera linea muestro el nombre de usuario, y en la siguiente muestro un botón para hacer logout.

Módulo y controlador

Implemento la lógica (de momento vacía) en app/userInfo/userInfo.js:

angular.module('userInfo', [])


.controller('UserInfoCtrl', function($scope, $state) {

    $scope.userName = "Demo user";

    var initView = function(){
        //Do any expected initialization
    }

    $scope.$on('$ionicView.beforeEnter', function(){
        initView();
    });

    $scope.logout = function(){
        //perform logout operation  
        $state.go('login');
    }

})

De momento, para que todo funcione, creo en el $scope un nombre de usuario por defecto, y una función logout que me devuelve a la vista anterior.

¿Y como queda esta vista?
Pues parecida a la anterior:

user info view

 

Probando la estructura

Ya hemos visto los pantallazos, pero aquí te dejo una demo interactiva:

See the Pen Ionin Navigation between 2 views demo by Enrique (@-kaik-) on CodePen.

 

Integrando autentificación con Google+

Ya tengo la estructura de la app, ahora voy a entrar en materia para integrar la autentificación de Google+.

Plugin de Google+

Lo primero es instalarse el plugin oficial de Google+ para Córdova/PhoneGap

Me instalo el plugin del siguiente modo:

googleLoginDemo$:cordova plugin add nl.x-services.plugins.googleplus
googleLoginDemo$:cordova prepare

Una vez hecho esto, el código de GooglePlus.js se añade automáticamente, por lo que no tenemos que tocar nada de nuestro html.

IMPORTANTE: Ninguno de estos métodos puede llamarse antes de que se invoque el evento deviceready (en nuestro caso, con ionic, podemos usar su equivalente $ionicPlatform.ready).

Este plugin te proporciona diversas funcionalidades, vamos a ver algunas:

isAvailable

Si quiero poner un botón de login con Google+, mejor que compruebe esto antes.

  • En iOS comprueba si Google+ está instalado, si no lo está y llamamos a la función de login, se redirigirá a Safari, que según comenta el creador del plugin, puede ser motivo de rechazo de una app en la app store.
  • En Android, comprueba si Google Play Services está instalado (probablemente lo esté).

Login

Intenta hacer el login a partir de la cuenta de Google+ que tengamos registrada en el dispositivo. No abre una vista para seleccionar la cuenta que queremos usar, y nos pide permisos para autentificarnos con Google+.
En caso de éxito, el callback nos devuelve un objeto obj en JSON con el siguiente contenido:

obj.email
obj.userId
obj.displayName
obj.gender
obj.imageUrl
obj.givenName
obj.middleName
obj.familyName
obj.birthday
obj.ageRangeMin
obj.ageRangeMax
obj.idToken
obj.oauthToken

Try silent login

La idea es utilizar este método cuando el usuario vuelve a la app, y no estoy seguro de si necesita autentificación. Con ella, intento logear al usuario. Si tengo éxito, obtengo de nuevo un objeto como el anterior obj. Si no, tengo un callback de error para gestionarlo, pero no me aparece en ningún momento el diálogo de selección de cuenta de usuario.

Logout

Elimina el token OAuth2.

Disconnect

Elimina el token OAuth2 y se olvida de qué cuenta se ha utilizado para hacer login.

Servicio para gestionar la autentificación

Como a mi me gusta el código ordenado, y prefiero utilizar dependency injection en la medida de lo posible, creo un nuevo fichero (lo haré en la nueva carpeta services: services/SocialAuthService.js), que me devolverá un singleton SocialAuth, con los siguientes métodos:

  • SocialAuth.getAuthUser: Me devuelve el objeto con el usuario, o bien null
  • SocialAuth.isGooglePlusAvailable: Promise que devuelve parámetro true/false
  • SocialAuth.googlePlusLogin: Promise con callbacks success/error
  • SocialAuth.googlePlusSilentLogin: Promise con callbacks success/error
  • SocialAuth.googlePlusLogout: Promise que devuelve un mensaje
  • SocialAuth.googlePlusDisconnect: Promise que devuelve un mensaje

Dejo el código a continuación
services/SocialAuthService.js:

angular.module('SocialAuthService', [])

/**
 * A service that handles methods related with Social authentication
 */
.factory('SocialAuth', function($http, $q) {

    var SocialAuth = {};

    var authUser = null;

    var isGooglePlusAPIAvailableOrReject = function(deferred){
        if(window.plugins == null || window.plugins.googleplus == null){
            setTimeout(function(){
                console.log("API not available");
                deferred.reject("API not available");
            }, 100);

            return false;
        }

        return true;
    }


    /**
     * @brief Static method that returns the authenticated user object
     *
     */
    SocialAuth.getAuthUser = function() {
        return authUser;
    };

    /**
     * @brief Static method that check whether google+ is available or not, 
     * returning a promise, with a unique parameter (true/false).
     *
     */
    SocialAuth.isGooglePlusAvailable = function() {
        var deferred = $q.defer();

        if(isGooglePlusAPIAvailableOrReject(deferred)){
            window.plugins.googleplus.isAvailable(
                    function (available) {
                      deferred.resolve(available);
                    }
                );          
        }


        //return the promise object
        return deferred.promise;
    };


    /**
     * @brief Static method that attempts to log-in using Google+ auth API.
     * This method returns a promise with success/error callbacks.
     * 
     */
    SocialAuth.googlePlusLogin = function() {
        var deferred = $q.defer();

        if(isGooglePlusAPIAvailableOrReject(deferred)){
            window.plugins.googleplus.login(
                {
                  'iOSApiKey': 'my_iOS_API_KEY_if_I_have_one'
                  // there is no API key for Android; you app is wired to the Google+ API by listing 
                  // your package name in the google dev console and signing your apk
                },
                function (obj) {
                    authUser = angular.fromJson(obj);
                    deferred.resolve(obj);
                },
                function (err) {
                    deferred.reject(err);
                }
            );
        }

        //get the promise object
        var promise = deferred.promise;

        //add success callback to the promise, and associate it with the RESOLVE call
        promise.success = function(fn) {
            return promise.then(function(response) {
                fn(response);
            })
        }

        //add success callback to the promise, and associate it with the REJECT call
        promise.error = function(fn) {
            return promise.then(null, function(response) {
                fn(response);
            })
        }

        //return the promise object
        return promise;
    }; 


    /**
     * @brief Static method that attempts to log-in silently using Google+ auth API.
     * This method returns a promise with success/error callbacks.
     * If it succeeds, you get the same object returned by googlePlusLogin. If it fails, it
     * will not show the auth dialog to the user
     * 
     * @returns obj The success callback gets a JSON object like the one returned by 
     * SocialAuth.googlePlusLogin
     * 
     * @see SocialAuth.googlePlusLogin
     */
    SocialAuth.googlePlusSilentLogin = function() {
        var deferred = $q.defer();

        if(isGooglePlusAPIAvailableOrReject(deferred)){
            window.plugins.googleplus.trySilentLogin(
                {
                  'iOSApiKey': 'my_iOS_API_KEY_if_I_have_one'
                },
                function (obj) {
                    console.log(obj);
                    authUser = angular.fromJson(obj);
                    deferred.resolve(obj);
                },
                function (err) {
                    deferred.reject(err);
                }
            );
        }

        //get the promise object
        var promise = deferred.promise;

        //add success callback to the promise, and associate it with the RESOLVE call
        promise.success = function(fn) {
            return promise.then(function(response) {
                fn(response);
            })
        }

        //add success callback to the promise, and associate it with the REJECT call
        promise.error = function(fn) {
            return promise.then(null, function(response) {
                fn(response);
            })
        }

        //return the promise object
        return promise;
    }; 


    /**
     * @brief Static method that attempts to clear the OAuth2 token 
     * returning a promise, with a unique message parameter
     *
     */
    SocialAuth.googlePlusLogout = function() {
        var deferred = $q.defer();

        if(isGooglePlusAPIAvailableOrReject(deferred)){
            window.plugins.googleplus.logout(
                    function (msg) {
                        authUser = null;
                        deferred.resolve(msg);
                    }
                );
        }

        //return the promise object
        return deferred.promise;
    };


    /**
     * @brief Static method that attempts to clear the OAuth2 token
     * and forget which account was used to log in (this forces the user to re-auth the app again) 
     * returning a promise, with a unique message parameter
     *
     */
    SocialAuth.googlePlusDisconnect = function() {
        var deferred = $q.defer();

        if(isGooglePlusAPIAvailableOrReject(deferred)){
            window.plugins.googleplus.disconnect(
                    function (msg) {
                        authUser = null;
                        deferred.resolve(msg);
                    }
                );
        }

        //return the promise object
        return deferred.promise;
    };    


    return SocialAuth;
});

 

Usando el login de Google

Login.js

Voy a modificar el script login.js para loguearme con Google+.

En primer lugar, incluyo el servicio para poderlo usar

angular.module('login', ['SocialAuthService'])


.controller('LoginCtrl', function($ionicPlatform, $scope, $state, SocialAuth) {
...
}

A continuación, creo una función que comprobará si la API de Google+ está disponible:

  • En caso negativo, oculta el botón de login
  • En caso afirmativo, intenta el login silencioso
    • Si lo consigue, pasa a la vista de usuario
    • Si no lo consigue, muestra el botón de login
var silentLoginAttempt = function(){

    SocialAuth.isGooglePlusAvailable()
    .then(function(available){
        console.log("google plus availability is: " + available);
        if(!available){
            $scope.showGoogleLogin = false;
        }
        else{
            var promise = SocialAuth.googlePlusSilentLogin();

            promise.success(function(msg){
                console.log("silent login success");
                $state.go('userInfo');
            });

            promise.error(function(err){
                console.log("silent login failed: " + err);
                $scope.showGoogleLogin = true;
            });
        }
    });
}

Para que esto tenga sentido, haremos cambios en la vista, que veremos más adelante.

Siguiendo con el javascript, actualizo la función initView de login.js. Ahora, si estoy logueado automáticamente pasa a la siguiente vista. Si no, llama a la función que acabo de crear para intentar el login silencioso:

    var initView = function(){

        $scope.showGoogleLogin = false;

        $ionicPlatform.ready(function(){
            if(SocialAuth.getAuthUser() == null){
                silentLoginAttempt();
            }
            else{
                $state.go('userInfo');
            }
        })  
    }

    $scope.$on('$ionicView.beforeEnter', function(){
        initView();
    });

 

Finalmente, en la función de login(), utilizo de nuevo mi servicio SocialAuth

    $scope.login = function(){

        var promise = SocialAuth.googlePlusLogin();

        promise.success(function(msg){
            $state.go('userInfo');
        });
        promise.error(function(err){
            alert("Invalid login!! Error: " + err);
        });
    }

 

Login.html

Como he comentado, tengo que actualizar la vista de login para utilizar la nueva variable que oculta o no el botón. Yo la dejo así.

<ion-view title="Login">
    <ion-content>
        <h3 class="padding">This view tries to log-in with Google+</h3>
        <br/>
        <div ng-show="showGoogleLogin"  class="padding">You're logged out. Please, log-in again</div>
        <button ng-show="showGoogleLogin" class="button button-assertive button-block padding" ng-click="login()">
            Google+ login!
        </button>
        <h4 class="padding" ng-hide="showGoogleLogin">Sorry, you cannot login with Google+</h4>
        <br/>
    </ion-content>
</ion-view>

 

UserInfo.js

La gracia de estar logueado es que tengo acceso a datos de la cuenta de Google del usuario. Por un lado su información básica, que utilizaré ahora para mostrar el nombre de usuario, pero por otro a su token de autentificación.

DETALLE: El token OAuth2 es interesante, por que me permitirá realizar peticiones a la API de Google+ para recuperar más datos del usuario, como sus contactos (en función de los permisos que tenga nuestra app).

¿Como cambio el código en userInfo.js?
Tengo que importar el módulo y servicio, por supuesto.
Además recupero el nombre de usuario a través del servicio, y actualizo la función de logout() para que use SocialAuth.

angular.module('userInfo', ['SocialAuthService'])

.controller('UserInfoCtrl', function($scope, $state, SocialAuth) {

    $scope.userName = "Demo user";

    var initView = function(){
        $scope.userName = SocialAuth.getAuthUser().displayName;
        console.log("user oAuth token: " + SocialAuth.getAuthUser().oauthToken);
    }

    $scope.$on('$ionicView.beforeEnter', function(){
        initView();
    });

    $scope.logout = function(){
        SocialAuth.googlePlusLogout().then(function(value){
            console.log("logout value: " + value);
            $state.go('login');
        })
    }

})

Fíjate que no hace falta cambiar mi template userInfo.html, ya he estoy mostrando el nombre de usuario a partir de la variable de entorno $scope.userName

 

En el siguiente vídeo puedes ver como funciona en un Android real.

 

Repositorio github

Lo prometido es deuda. Si quieres trastear con el código, en mi repositorio github para demos de ionic, encontrarás el código utilizado, en la carpeta googleLoginDemo.

Si te ha gustado este artículo, ¡no olvides compartirlo!

Saludos