Social
Serverless
Autenticación sin PASSWORD (Node.js + MongoDB + JWT + Mailgun + Serverless)
Fri May 22 2020 19:00:00 GMT-0500 (Peru Standard Time)

Puedo iniciar con una pregunta ¿Cuantas veces al día necesitas escribir un password?. Una, dos, tres! Aquí el problema, si problema. Un gran porcentaje de usuarios re-utilizan el mismo password en todos los sitios/apps y si uno de estos se ve comprometido toda nuestra información será accesible. La recomendación inicial es usar un password distinto en cada lugar pero si seguimos todos los requisitos para cada una por ejemplo mínimo 8 caracteres, una mayúscula, un carácter especial nos encontramos con un nivel de complejidad exponencial y tener que recordarlo es algo complicado.

Y que tal si nos enfocamos en mantener solo un lugar seguro con un password lo suficientemente complejo que nos mantenga seguros. Es así que la primera opción será nuestro correo electrónico y redirigir toda autenticación a través de nuestra cuenta de correo.

La autenticación sin password (passwordless authentication), nos permite enviar un “texto/código/frase de verificación” al correo electrónico del usuario y con esto comprobar su identidad.

Veamos un diagrama de lo que queremos construir:

Recibimos la dirección de correo en nuestro end-point login (AWS Lambda), validamos si la dirección existe en nuestra base de datos (MongoDB) en el caso de no existir retornamos un mensaje, si la cuenta existe generamos un código de 4 dígitos y lo enviamos por correo (Mailgun) al usuario.

El usuario recibe el código y lo ingresa en la pantalla de verificación, el código + dirección de correo lo enviamos a nuestro end-point validate (AWS Lambda), comprobamos que el código sea válido y le pertenece al usuario quien lo envía, si todo esta correcto generamos un token con JWT y retornamos al usuario. A partir de este momento cada request que el usuario genere estará firmada por el token JWT. Todo esto sin necesidad de un password, confiamos que el usuario mantendrá seguro el acceso a su correo.

AWS Lambda

Nuestro sistema de autenticación lo desplegaremos como Funciones Lambda en AWS, para esto es necesario una cuenta en AWS y un usuario creado a través de IAM (Identity and Access Management).

Este proceso lo puedes revisar a detalle en el sitio Serverless-Stack (Crear un usuario IAM). Una vez obtenemos las credenciales las debemos almacenar en un lugar seguro.

API

Definamos la estructura general de nuestro proyecto.

paswordless-lambda
    │   package.json
    │   serverless.yml
    │   secrets.json
    └───src
        └───util
            │   DB.js
            │   Email.js
        └───model
            │   Code.js
            │   User.js
        └───controller
            │   User.js
        └───handler
            │   User.js

Nuestro API los construiremos con Serverless Framework, nos provee la capacidad de desarrollar, desplegar y monitorear aplicaciones serverless sobre AWS y gracias a este framework nos enfocamos en el desarrollo y no en todo el proceso de despliegue que conlleva empaquetar el código, subirlo a S3 y crear los end-point en Amazon API Gateway todo esto habilitando políticas de acceso entre todos estos servicios.

Lo instalamos de manera global a nuestro entorno de desarrollo:

$ npm install -g serverless

Ahora debemos configurar las credenciales previamente obtenidas con IAM:

$ serverless config credentials --provider aws --key xxxxxxxxxxxxxx --secret xxxxxxxxxxxxxx

Listo nos queda crear nuestro proyecto a través de serverless, indicamos que usaremos Node.js en AWS, este framework permite trabajar sobre Google Cloud y Azure:

$ serverless create -t aws-nodejs -p passwordless-lambda && cd passwordless-lambda

Como primer paso definamos la configuración inicial (secrets.json) y los end-point(serverless.yaml). En el archivo secrets.json debemos tener varios detalles en consideración, almacenamos la URL de nuestra base de datos en MongoDB, el secret para generar nuestros token con JWT, el token de Mailgun el dominio y el dirección del remitente. IMPORTANTE: este archivo no se incluye en el repositorio de este proyecto, lo debes crear en la raíz del mismo. Nunca debes incluir esta información en el repositorio es por eso que se ignora el archivo en git.

secrets.json

{
    "DB": "[MongoDB ATLAS DB URL]",
    "JWT_SECRET": "[MI HASH SECRETO]",
    "MAILGUN_API_KEY": "[HASH KEY]",
    "MAILGUN_DOMAIN": "[DOMAIN]",
	"MAILGUN_FROM": "[REMITENTE - DEBE SER UNA CUENTA CON EL MISMO DOMINIO]"
}

Con nuestro archivo secrets.json listo procedemos a modificar nuestro archivo principal serverless.yml desde donde el framework construirá y desplegará las funciones. Al trabajar en este archivo debemos ser cuidadosos con la indentación al estar en formato YAML eso es muy importante.

En la propiedad custom definimos desde donde obtendra los secrets nuestro proyecto y lineas abajo podemos hacer referencia como variables de entorno (environment) a las propiedades en nuestro secrets.json.

Definimos las dos funciones login y validate, cada una en la propiedad handler especifica el punto de entrada y en events el método (en este caso POST), el path y si habilitamos CORS a nuestro end-point.

En nuestro proceso de desarrollo no queremos desplegar cada cambio para hacer pruebas verdad!, es por eso que vamos a utilizar el plugin serverless-offline el cual nos permite arrancar estas funciones de manera local.

serverless.yml

service: passwordless-lambda

custom:
  secrets: ${file(secrets.json)}

provider:
  name: aws
  runtime: nodejs12.x
  stage: dev
  region: us-east-1
  environment:
    JWT_SECRET: ${self:custom.secrets.JWT_SECRET}
    DB: ${self:custom.secrets.DB}
    MAILGUN_API_KEY: ${self:custom.secrets.MAILGUN_API_KEY}
    MAILGUN_DOMAIN: ${self:custom.secrets.MAILGUN_DOMAIN}
    MAILGUN_FROM: ${self:custom.secrets.MAILGUN_FROM}

functions:
  login:
    handler: src/handler/User.login
    events:
      - http:
          path: user/login
          method: post
          cors: true

  validate:
    handler: src/handler/User.validate
    events:
      - http:
          path: user/validate
          method: post
          cors: true

plugins:
  - serverless-offline

Vamos a añadir todos los paquetes necesarios para este proyecto:

$ npm install mailgun-js mongoose jsonwebtoken serverless-offline

Para mantener un orden en el código y que a futuro esta estructura sea escalable agrupamos los modelos, los controladores hacen uso de los modelos y utilidades por ejemplo acceso a la DB o envío de email y finalmente el grupo handler donde expondremos la función como un lambda.

En nuestro modelo User mantenemos información básica:

src/model/User.js

const mongoose = require("mongoose");

const UserSchema = new mongoose.Schema({
    firstname: String,
    lastname: String,
    email: String
});

mongoose.model("User", UserSchema);

module.exports = mongoose.model("User");

En el modelo Code almacenaremos el email y el código de validación del usuario:

src/model/Code.js

const mongoose = require("mongoose");

const CodeSchema = new mongoose.Schema({
    email: String,
    code: String
});

mongoose.model("Code", CodeSchema);

module.exports = mongoose.model("Code");

Definimos nuestro controlador User

src/controller/User.js

const JWT = require("jsonwebtoken");

const DB = require("../utils/DB");
const email = require("../utils/email");
const UserModel = require("../model/User");
const CodeModel = require("../model/Code");

module.exports = class User {
    async login(payload) {
        try {
            await DB.connect();

            const user = await UserModel.findOne({ email: payload.email });

            if (!user) {
                return {
                    statusCode: 200,
                    headers: { "Content-Type": "application/json" },
                    body: {
                        status: "error",
                        message: "user not found"
                    }
                }
            }
						
            // Code entre 0 y 9999
            const randomCode = Math.floor(Math.random() * 10000);

            // Eliminamos códigos pre-existentes, con esto invalidamos intentos previos de login
            await CodeModel.remove({ email: payload.email });
    
            // Guardamos el nuevo intento de login
            await CodeModel.create({
                    email: payload.email,
                    code: randomCode
            });

            // Enviamos el código generado hacia el correo electrónico del usuario
            // Usamos un email template de Mailgun donde podemos aplicar el diseño necesario
            // email([EMAIL DESTINO], [EMAIL SUBJECT], [TEMPLATE], [PAYLOAD])
            email(payload.email, "NodeHispano - Ingresa a tu cuenta", "login_message", {code: randomCode});

            return {
                statusCode: 200,
                headers: { "Content-Type": "application/json" },
                body: {
                    status: "ok",
                    message: "login attempt",
                }
            }
        } catch (err) {
            return {
                statusCode: 200,
                headers: { "Content-Type": "application/json" },
                body: {
                    status: "error",
                    message: err.message
                }
            }
        }
    }

    async validate(payload) {
        try {
            await DB.connect();

            // Verificamos que exista un código asociado al email
            const code = await CodeModel.findOne({
                    email: payload.email,
                    code: payload.code
            });

            // De no existir retornamos una respuesta con el mensaje correspondiente
            if (!code) {
                return {
                    statusCode: 200,
                    headers: { "Content-Type": "application/json" },
                    body: {
                        status: "error",
                        message: "login attempt"
                    }
                }
            }

            // Si todo esta correcto obtenemos la información del User
            // Generamos un token con la información del usuario (Válido por 1 día)
            // Firmamos el payload del token con JWT y el secret de nuestras variables de entorno
            // Finalmente enviamos como response el nuevo token
            const user = await UserModel.findOne({ email: payload.email });
						
			const tokenPayload = {
                id: user.id,
                email: payload.email
            }
            const token = JWT.sign(tokenPayload, process.env.JWT_SECRET, { expiresIn: "1d" });

            return {
                statusCode: 200,
                headers: { "Content-Type": "application/json" },
                body: {
                    status: "ok",
                    message: "login valid",
                    token: token
                }
            }
        } catch (err) {
            return {
                statusCode: 200,
                headers: { "Content-Type": "application/json" },
                body: {
                    status: "error",
                    message: err.message
                }
            }
        }
    }
}

Ahora que tenemos completa la lógica del controller User necesitamos exponer estos métodos como funciones lambda a través del handler User.js

Creamos una instancia de la Class User (Controller) con eso ejecutamos el método login/validate y como parámetros le enviamos el body que nos llega en la variable event de la función.

De ejecutarse satisfactoriamente retornamos la respuesta al usuario caso contrario todo error será capturado y se retornará un mensaje de error con su código correspondiente.

src/handler/User.js

const User = require("../controller/User");

module.exports.login = async (event, context) => {
    context.callbackWaitsForEmptyEventLoop = false;

    try {
        const userInstance = new User();
        const response = await userInstance.login(JSON.parse(event.body));

        return {
            statusCode: 200,
            body: JSON.stringify(response)
        }
    } catch (err) {
        return {
            statusCode: err.statusCode || 500,
            headers: { "Content-Type": "application/json" },
            body: {
                status: "error",
                message: err.message
            }
        }
    }
}

module.exports.validate = async (event, context) => {
    context.callbackWaitsForEmptyEventLoop = false;

    try {
        const userInstance = new User();
        const response = await userInstance.validate(JSON.parse(event.body));

        return {
            statusCode: 200,
            body: JSON.stringify(response)
        }
    } catch (err) {
        return {
            statusCode: err.statusCode || 500,
            headers: { "Content-Type": "application/json" },
            body: {
                status: "error",
                message: err.message
            }
        }
    }
}

Nos queda un paso antes de probar el código descrito anteriormente, debemos crear el template en Mailgun.

Mailgun

En el dashboard de Mailgun seleccionamos el ‘Templates’ y a continuación ‘Create Message Template’.

Colocamos el nombre del template en este caso: login_message, una descripción y el formato del mensaje con la variable {{code}} en el body.

Test local

Estamos cerca del final, nos queda verificar que todas las partes de este proyecto funcionen en conjunto para eso lo haremos de manera local gracias a: serverless-offline

Ejecutamos el siguiente comando:

$ serverless offline start

A continuación nos muestra todos los end-point disponibles en nuestro proyecto.

offline: Starting Offline: dev/us-east-1.
offline: Offline [http for lambda] listening on http://localhost:3002

   ┌────────────────────────────────────────────────────────────────────────────┐
   │                                                                            │
   │   POST | http://localhost:3000/dev/user/login                              │
   │   POST | http://localhost:3000/2015-03-31/functions/login/invocations      │
   │   POST | http://localhost:3000/dev/user/validate                           │
   │   POST | http://localhost:3000/2015-03-31/functions/validate/invocations   │
   │                                                                            │
   └────────────────────────────────────────────────────────────────────────────┘

offline: [HTTP] server ready: http://localhost:3000 🚀

Despliegue

Listo tenemos todo funcionando, nos queda hacer un deploy de nuestras funciones por lo que gracias a Serverless Framework lo podemos hacer con un comando.

$ serverless deploy

De igual manera obtendremos como respuesta los end-point que por medio de API Gateway ejecutaran nuestras funciones lambda almacenadas en S3.

Código

passwordless-lambda

Author
Carlos G. Rodriguez

Autor y creador de NodeHispano