Autenticar usuarios con Node.js

En esta parte del tutorial de Bookshelf para Node.js, se muestra cómo crear un flujo de inicio de sesión para los usuarios y cómo usar la información del perfil para ofrecer a los usuarios funciones personalizadas.

Con Google Identity Platform, puedes acceder fácilmente a la información sobre los usuarios y al mismo tiempo garantizar que Google administre sus credenciales de inicio de sesión de forma segura. OAuth 2.0 facilita el flujo de inicio de sesión para todos los usuarios de la aplicación y proporciona a la aplicación acceso a información básica del perfil sobre usuarios autenticados.

Esta página forma parte de un tutorial de varias páginas. Para empezar desde lo básico y consultar las instrucciones de configuración, ve a la aplicación Bookshelf de Node.js.

Crear una ID de cliente de aplicación web

Un ID de cliente de aplicación web permite que la aplicación autorice a los usuarios y acceda a las API de Google en nombre de los usuarios.

  1. Ve a la sección de credenciales en la consola de Google Cloud Platform.

  2. Haz clic en la pantalla de autorización de OAuth. Introduce Node.js Bookshelf App en el nombre del producto. Completa los campos opcionales relevantes. Haz clic en Guardar.

  3. Haz clic en Crear credenciales > ID de cliente OAuth.

  4. En Tipo de aplicación, selecciona Aplicación web.

  5. En Nombre, introduce Node.js Bookshelf Client.

  6. En URI de redirección autorizadas, introduce las siguientes URL, una a la vez. Sustituye [YOUR_PROJECT_ID] por el ID del proyecto:

    http://localhost:8080/auth/google/callback
    http://[YOUR_PROJECT_ID].appspot.com/auth/google/callback
    https://[YOUR_PROJECT_ID].appspot.com/auth/google/callback
    http://[YOUR_PROJECT_ID].appspot-preview.com/auth/google/callback
    https://[YOUR_PROJECT_ID].appspot-preview.com/auth/google/callback

  7. Haz clic en Crear.

  8. Copia el ID de cliente y secreto de cliente y guárdalos para usarlos más tarde.

Crear una instancia Memcached

Si quieres almacenar correctamente las sesiones en varias máquinas virtuales de App Engine, se debe almacenar los datos de sesión en ubicaciones a las que puedan acceder todas las instancias de App Engine. Para ello, usaremos una instancia Memcache para almacenar nuestros datos de sesión entre servidores.

Si aún no tienes una instancia Memcached, obtén una gratis en Redis Labs. Si utilizas Redis Labs, la propiedad Endpoint muestra la URL que apunta a la instancia Memcached.

Establecer configuración

Copia el archivo config.json de la parte de Cloud Storage de este tutorial al directorio nodejs-getting-started/4-auth. Agrega las siguientes líneas al archivo copiado:

"OAUTH2_CLIENT_ID": "[YOUR_OAUTH2_CLIENT_ID]",
"OAUTH2_CLIENT_SECRET": "[YOUR_OAUTH2_CLIENT_SECRET]",
"OAUTH2_CALLBACK": "http://localhost:8080/auth/google/callback",
"MEMCACHE_URL": "[YOUR_MEMCACHE_URL]"
  1. Sustituye [YOUR_OAUTH2_CLIENT_ID] y [YOUR_OAUTH2_CLIENT_SECRET] por el ID y el secreto de cliente de la aplicación que creaste anteriormente.
  2. Sustituye [YOUR_MEMCACHE_URL] por una URL que apunte a la instancia Memcached. Esta URL debe tener el formato hostname:port.

Instalar dependencias

Instala dependencias en el directorio nodejs-getting-started/4-auth:

  • Con npm:

    npm install
    
  • Con yarn:

    yarn install
    

Ejecutar la aplicación en la máquina local

  1. Usa npm o yarn para iniciar un servidor web local:

    • Con npm:

      npm start
      
    • Con yarn:

      yarn start
      
  2. Introduce la siguiente dirección en el navegador web.

    http://localhost:8080

Ahora puedes navegar por las páginas web de la aplicación, iniciar sesión con tu cuenta de Google, agregar libros y ver los libros que agregaste mediante el enlace Mis libros en la barra de navegación superior.

Desplegar la aplicación en el entorno flexible de App Engine

  1. Cambia la línea OAUTH2_CALLBACK de config.json por la siguiente para habilitar la autenticación online. Sustituye [YOUR_PROJECT_ID] por el ID del proyecto:

    "OAUTH2_CALLBACK": "https://[YOUR_PROJECT_ID].appspot.com/auth/google/callback"
    
  2. Despliega la aplicación de muestra:

    gcloud app deploy
    

  3. Introduce la siguiente dirección en el navegador web. Sustituye [YOUR_PROJECT_ID] por el ID del proyecto:

    https://[YOUR_PROJECT_ID].appspot.com
    

Si actualizas la aplicación, puedes desplegar la versión actualizada al introducir el mismo comando que utilizaste para desplegar la aplicación por primera vez. El nuevo despliegue crea una nueva versión de la aplicación y la promociona a la versión predeterminada. Las versiones anteriores de la aplicación se mantienen, al igual que las instancias de VM asociadas. Ten en cuenta que todas estas versiones de aplicaciones e instancias de VM son recursos facturables.

Si eliminas las versiones no predeterminadas de la aplicación, puedes reducir los costes.

Para eliminar una versión de la aplicación, sigue las instrucciones a continuación:

  1. En Cloud Console, ve a la página Versiones de App Engine.

    Ir a Versiones

  2. Selecciona la casilla de verificación de la versión no predeterminada de la app que deseas borrar.
  3. Para borrar la versión de la app, haz clic en Borrar.

Si quieres obtener información completa sobre cómo limpiar los recursos facturables, consulta la sección sobre limpiar los recursos en el último paso de este tutorial.

Estructura de la aplicación

En el siguiente diagrama, se muestran los componentes de la aplicación y cómo se conectan entre ellos.

Estructura de muestra de autenticación

Información sobre el código

En esta sección, se explica detalladamente el código de la aplicación y su funcionamiento.

Sobre las sesiones

Antes de que la aplicación pueda autenticar a los usuarios, necesitas almacenar información sobre el usuario actual en una sesión de alguna forma. Express incluye una implementación basada en memoria, pero no es adecuada para una aplicación que se puede enviar de varias instancias de VM, ya que la sesión que se registra en una instancia puede diferir de otras instancias. La muestra de Bookshelf utiliza un objeto MemcachedStore para almacenar datos de sesión en un servicio Memcache remoto especificado en config.json.

// Configure the session and session storage.
const sessionConfig = {
  resave: false,
  saveUninitialized: false,
  secret: config.get('SECRET'),
  signed: true
};

// In production use the App Engine Memcache instance to store session data,
// otherwise fallback to the default MemoryStore in development.
if (config.get('NODE_ENV') === 'production' && config.get('MEMCACHE_URL')) {
  sessionConfig.store = new MemcachedStore({
    hosts: [config.get('MEMCACHE_URL')]
  });
}

app.use(session(sessionConfig));

Autenticar usuarios

La muestra de Bookshelf usa Passport.js para administrar la autenticación de usuarios. Passport.js requiere cierta configuración antes de poder autenticar usuarios:

const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

function extractProfile (profile) {
  let imageUrl = '';
  if (profile.photos && profile.photos.length) {
    imageUrl = profile.photos[0].value;
  }
  return {
    id: profile.id,
    displayName: profile.displayName,
    image: imageUrl
  };
}

// Configure the Google strategy for use by Passport.js.
//
// OAuth 2-based strategies require a `verify` function which receives the
// credential (`accessToken`) for accessing the Google API on the user's behalf,
// along with the user's profile. The function must invoke `cb` with a user
// object, which will be set at `req.user` in route handlers after
// authentication.
passport.use(new GoogleStrategy({
  clientID: config.get('OAUTH2_CLIENT_ID'),
  clientSecret: config.get('OAUTH2_CLIENT_SECRET'),
  callbackURL: config.get('OAUTH2_CALLBACK'),
  accessType: 'offline'
}, (accessToken, refreshToken, profile, cb) => {
  // Extract the minimal profile information we need from the profile object
  // provided by Google
  cb(null, extractProfile(profile));
}));

passport.serializeUser((user, cb) => {
  cb(null, user);
});
passport.deserializeUser((obj, cb) => {
  cb(null, obj);
});

Los permisos indican los elementos de la información del usuario a los que la aplicación tiene acceso. Google brinda múltiples servicios junto con un conjunto de ámbitos para cada servicio. Por ejemplo, hay un permiso que permite el acceso de solo lectura a Google Drive y otro permiso que permite el acceso de lectura y escritura. Esta muestra solo requiere los permisos básicos profile y email, que otorgan a la aplicación acceso a la dirección de correo electrónico del usuario y a la información básica del perfil.

Autenticar al usuario implica dos pasos básicos, que en conjunto se denominan flujo de servicio web:

  1. Redirigir al usuario al servicio de autorización de Google.
  2. Procesar la respuesta cuando Google redirige al usuario a la aplicación.

Redirigir a Google

La aplicación envía al usuario al servicio de autorización de Google. La aplicación genera una URL mediante el uso del ID de cliente y los permisos que necesita la aplicación.

router.get(
  // Login url
  '/auth/login',

  // Save the url of the user's current page so the app can redirect back to
  // it after authorization
  (req, res, next) => {
    if (req.query.return) {
      req.session.oauth2return = req.query.return;
    }
    next();
  },

  // Start OAuth 2 flow using Passport.js
  passport.authenticate('google', { scope: ['email', 'profile'] })
);

Luego, la aplicación redirige al usuario a la URL generada. Se solicita al usuario que permita que la aplicación acceda a los permisos especificados. Después de haber aceptado, se redirigirá al usuario de vuelta a la aplicación.

Procesar la respuesta de autorización

Google envía al usuario de vuelta a la aplicación con un código de autorización. Este código, junto con el secreto de cliente, se puede intercambiar por las credenciales del usuario y otra información sobre él. Este paso del flujo ocurre en el servidor en su totalidad, sin tener que redirigir al usuario. Este otro paso es necesario para verificar que el código de autorización es auténtico y que quien solicita las credenciales es tu aplicación. Passport.js facilita este paso:

router.get(
  // OAuth 2 callback url. Use this url to configure your OAuth client in the
  // Google Developers console
  '/auth/google/callback',

  // Finish OAuth 2 flow using Passport.js
  passport.authenticate('google'),

  // Redirect back to the original page, if any
  (req, res) => {
    const redirect = req.session.oauth2return || '/';
    delete req.session.oauth2return;
    res.redirect(redirect);
  }
);

Tras haber obtenido las credenciales, puedes almacenar información sobre el usuario. Passport.js serializa automáticamente al usuario para la sesión. Una vez que la información del usuario esté en la sesión, puede crear un par de funciones de middleware para facilitar el trabajo con autenticación:

// Middleware that requires the user to be logged in. If the user is not logged
// in, it will redirect the user to authorize the application and then return
// them to the original URL they requested.
function authRequired (req, res, next) {
  if (!req.user) {
    req.session.oauth2return = req.originalUrl;
    return res.redirect('/auth/login');
  }
  next();
}

// Middleware that exposes the user's profile as well as login/logout URLs to
// any templates. These are available as `profile`, `login`, and `logout`.
function addTemplateVariables (req, res, next) {
  res.locals.profile = req.user;
  res.locals.login = `/auth/login?return=${encodeURIComponent(req.originalUrl)}`;
  res.locals.logout = `/auth/logout?return=${encodeURIComponent(req.originalUrl)}`;
  next();
}

Puedes usar la información provista por el middleware en las plantillas para indicar al usuario que está conectado o desconectado:

if profile
  if profile.image
    img.img-circle(src=profile.image, width=24)
  span #{profile.displayName}  
    a(href=logout) (logout)
else
  a(href=login) Login

Personalización

Ahora que tienes la información del usuario que ha iniciado sesión disponible a través del middleware, puedes estar al tanto de los libros que subió cada usuario en la base de datos.

router.post(
  '/add',
  images.multer.single('image'),
  images.sendUploadToGCS,
  (req, res, next) => {
    const data = req.body;

    // If the user is logged in, set them as the creator of the book.
    if (req.user) {
      data.createdBy = req.user.displayName;
      data.createdById = req.user.id;
    } else {
      data.createdBy = 'Anonymous';
    }

    // Was an image uploaded? If so, we'll use its public URL
    // in cloud storage.
    if (req.file && req.file.cloudStoragePublicUrl) {
      data.imageUrl = req.file.cloudStoragePublicUrl;
    }

    // Save the data to the database.
    getModel().create(data, (err, savedData) => {
      if (err) {
        next(err);
        return;
      }
      res.redirect(`${req.baseUrl}/${savedData.id}`);
    });
  }
);

Ahora que tienes esa información en la base de datos, puedes mostrar al usuario qué libros han agregado por su cuenta a la base de datos.

// Use the oauth2.required middleware to ensure that only logged-in users
// can access this handler.
router.get('/mine', oauth2.required, (req, res, next) => {
  getModel().listBy(
    req.user.id,
    10,
    req.query.pageToken,
    (err, entities, cursor, apiResponse) => {
      if (err) {
        next(err);
        return;
      }
      res.render('books/list.pug', {
        books: entities,
        nextPageToken: cursor
      });
    }
  );
});

Este código usa un nuevo método de modelo llamado listBy. La implementación depende del backend de base de datos que se elija.

Datastore

function listBy (userId, limit, token, cb) {
  const q = ds.createQuery([kind])
    .filter('createdById', '=', userId)
    .limit(limit)
    .start(token);

  ds.runQuery(q, (err, entities, nextQuery) => {
    if (err) {
      cb(err);
      return;
    }
    const hasMore = nextQuery.moreResults !== Datastore.NO_MORE_RESULTS ? nextQuery.endCursor : false;
    cb(null, entities.map(fromDatastore), hasMore);
  });
}

Cloud SQL

function listBy (userId, limit, token, cb) {
  token = token ? parseInt(token, 10) : 0;
  connection.query(
    'SELECT * FROM `books` WHERE `createdById` = ? LIMIT ? OFFSET ?',
    [userId, limit, token],
    (err, results) => {
      if (err) {
        cb(err);
        return;
      }
      const hasMore = results.length === limit ? token + results.length : false;
      cb(null, results, hasMore);
    });
}

MongoDB

function listBy (userid, limit, token, cb) {
  token = token ? parseInt(token, 10) : 0;
  if (isNaN(token)) {
    cb(new Error('invalid token'));
    return;
  }
  getCollection((err, collection) => {
    if (err) {
      cb(err);
      return;
    }
    collection.find({createdById: userid})
      .skip(token)
      .limit(limit)
      .toArray((err, results) => {
        if (err) {
          cb(err);
          return;
        }
        const hasMore =
          results.length === limit ? token + results.length : false;
        cb(null, results.map(fromMongo), hasMore);
      });
  });
}