Protege tu API Rest mediante JSON Web Tokens

1
15624

En este artículo veremos cómo proteger una API REST empleando JSON Web Tokens

Índice de contenidos

1. Introducción

En este artículo veremos cómo proteger una API REST empleando JSON Web Tokens. Será una API sencillita, la típica aplicación de notas (sí, ya se que no es muy original, que le vamos a hacer :-/)

Crearemos un pequeño proyecto de ejemplo de API REST con Node.js + Express.js, gestionando la autenticación mediante la librería Passport.js, y además empleando Typescript (3×1 en tutoriales ;-)) . Puedes clonar el repositorio https://github.com/DaniOtero/jwt-express-demo

Para la realización de este tutorial se da por hecho que se cuenta con un entorno con Node.js correctamente configurado.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 15’ (2.5 GHz Intel Core i7, 16 GB 1600 MHz DDR3).
  • Sistema Operativo: Mac OS X El Capitán 10.11.2
  • Google Chrome 50.0.2661.102
  • Node.js 6.2.2
  • Postman
  • Atom 1.8.0

3. JWT

JSON Web Token (de ahora en adelante JWT) es un estándar abierto (RFC 7519) que nos permite la transmisión de forma segura y confiable gracias a su firma digital. Los token pueden ser firmados mediante clave simétrica (algoritmo HMAC) o mediante clave asimétrica (RSA)

Algunas de las ventajas de los JWT son su reducido tamaño, ya que al ser JSON tan solo añaden unos pocos bytes a nuestras peticiones contra el servidor, y otra de las mayores ventajas es que el payload del JWT contiene toda la información que necesitemos sobre el usuario, de forma que se evita la necesidad de repetir consultas a base de datos para obtener estos datos.

Los escenarios mas comunes donde utilizar JWT son en la autenticación, y en el intercambio de información (como pueden ser firmados mediante clave asimétrica, se puede verificar la identidad del emisor del mensaje, y puesto que la firma del JWT se calcula empleando el payload, también se verifica que el contenido del mensaje no ha sido manipulado)

4. Dependencias y configuración del entorno

Para facilitar todo lo relativo al proceso de transpilacion al estar utilizando TypeScript se utilizará la herramienta gulp. También se utilizará la herramienta typings para la gestión de las definiciones de TypeScript. Se pueden instalar ambos de forma global utilizando el comando

npm install -g gulp typings

Una vez hecho esto, crearemos un directorio donde albergar el proyecto, y sobre dicho directorio ejecutaremos el comando «npm init» y una vez generado el fichero package.json lo editaremos para añadir las dependencias.

{
  "name": "jwt-rest",
  "version": "1.0.0",
  "description": "",
  "main": "main.ts",
  "scripts": {
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "body-parser": "~1.13.2",
    "cookie-parser": "~1.3.5",
    "debug": "~2.2.0",
    "express": "~4.13.1",
    "morgan": "~1.6.1",
    "passport": "^0.3.2",
    "passport-jwt": "^2.1.0",
    "underscore": "^1.8.3"
  },
  "devDependencies": {
      "gulp": "^3.9.0",
      "gulp-clean": "^0.3.1",
      "gulp-develop-server": "^0.5.0",
      "gulp-mocha": "^2.2.0",
      "gulp-typescript": "^2.10.0"
  }
}

Tras editarlo ejecutaremos «npm install» para descargar las dependencias. Ahora configuraremos las definiciones de TypeScript, primero ejecutamos el comando «typings init», que nos generará el fichero typings.json, y que editaremos con lo siguiente:

{
    "name": "jwt-rest",
    "dependencies": {},
    "globalDependencies": {
        "bcrypt": "registry:dt/bcrypt#0.0.0+20160316155526",
        "express": "registry:dt/express#4.0.0+20160317120654",
        "express-serve-static-core": "registry:dt/express-serve-static-core#0.0.0+20160625155614",
        "jsonwebtoken": "registry:dt/jsonwebtoken#0.0.0+20160521152605",
        "mime": "registry:dt/mime#0.0.0+20160316155526",
        "node": "registry:dt/node#6.0.0+20160621231320",
        "passport": "registry:dt/passport#0.2.0+20160317120654",
        "passport-jwt": "registry:dt/passport-jwt#2.0.0+20160330163949",
        "passport-strategy": "registry:dt/passport-strategy#0.2.0+20160316155526",
        "serve-static": "registry:dt/serve-static#0.0.0+20160606155157",
        "typescript": "registry:dt/typescript#0.4.0+20160317120654",
        "underscore": "registry:dt/underscore#1.7.0+20160622050840"
    }
}

Crearemos nuestras tareas de gulp en el fichero gulpfile.js

var gulp = require('gulp');
var ts = require('gulp-typescript');
var clean = require('gulp-clean');
var server = require('gulp-develop-server');
var mocha = require('gulp-mocha');

var serverTS = ["**/*.ts", "!node_modules/**", '!bin/**'];

gulp.task('ts', ['clean'], function() {
    return gulp
        .src(serverTS, {base: './'})
        .pipe(ts({ module: 'commonjs', noImplicitAny: true }))
        .pipe(gulp.dest('./'));
});

gulp.task('clean', function () {
    return gulp
        .src([
            'app.js',
            '**/*.js',
            '**/*.js.map',
            '!node_modules/**',
            '!gulpfile.js',
            '!bin/**'
        ], {read: false})
        .pipe(clean())
});

gulp.task('server:start', ['ts'], function() {
    server.listen({path: 'main.js'}, function(error) {
        console.log(error);
    });
});

gulp.task('server:restart', ['ts'], function() {
    server.restart();
});

gulp.task('default', ['server:start'], function() {
    gulp.watch(serverTS, ['server:restart']);
});

Y por último el fichero tsconfig.json con la configuración del transpilador de TypeScript.

{
    "compilerOptions": {
        "target": "es5",
        "module": "commonjs",
        "moduleResolution": "node",
        "isolatedModules": false,
        "jsx": "react",
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true,
        "declaration": false,
        "noImplicitAny": false,
        "noImplicitUseStrict": false,
        "removeComments": true,
        "noLib": false,
        "preserveConstEnums": true,
        "suppressImplicitAnyIndexErrors": true
    },
    "exclude": [
        "node_modules"
    ],
    "compileOnSave": true,
    "buildOnSave": false,
    "atom": {
        "rewriteTsconfig": false
    },
    "filesGlob": ["**/*.ts"]
}

Con esto ya tendríamos el entorno preparado

5. Esqueleto del proyecto

Creamos 3 carpetas, «endpoints», «models» y «services». Comenzaremos por los modelos, para ello dentro de la carpeta «models» crearemos el fichero «user.ts»:

import {Note} from './note';

export interface User {
    id : string
    password: string,
    notes?: [Note]
}

Y el fichero «note.ts»

export interface Note {
    name: string;
    text: string;
}

Ahora creamos nuestro servicio. Para simplificar el tutorial, se prescindirá de cualquier tipo de base de datos, y lo que se utilizará será una lista de objetos en memoria. Crearemos el fichero «services/user.service.ts»:

import {User} from '../models/user';
import {Note} from '../models/note';
import * as _ from 'underscore';

export class UserService {
    private static users : [User] = [
        {id: "test", password: "1234"},
        {id: "test2", password: "1234"},
        {id: "test3", password: "1234"},
    ];

    static findById(id: string) : User {
        let user : User = _.find(this.users, (user)=> {
            return user.id === id;
        })
        return user;
    }

    static addNote(id: string, note: Note) {
        let user = this.findById(id);
        if(!user || !note) {
            throw new Error("Bad request");
        } else {
            if (!user.notes) {
                user.notes = [note];
            } else {
                user.notes.push(note);
            }
        }
    }
}

Ahora crearemos los endpoint de la API. Creamos el fichero «endpoints/user.ts»:

import {Request, Response} from "express";
var express = require('express');
var router = express.Router();
import {UserService} from '../services/user.service'
import jsonwebtoken = require ('jsonwebtoken')

router.post('/login', function(req: Request, res: Response, next: Function) {
    let username = req.body.username;
    let password = req.body.password;
    let user = UserService.findById(username);
    if(user && (password === user.password)) {
        res.sendStatus(200);
    } else {
        res.sendStatus(403);
    }
});

export default router;

Y el fichero «endpoints/notes.ts»

import {UserService} from '../services/user.service';
import {Note} from '../models/note';

import {Request, Response} from "express";
var express = require('express');
var router = express.Router();
var passport = require('passport');


router.get('/:user_id', { session: false}), (req: Request, res: Response, next: Function) => {
    let id = req.params.user_id;
    try {
        let notes = UserService.findById(id).notes;
        res.json({notes: notes});
    } catch(err) {
        res.sendStatus(400);
    }
});

router.post('/:user_id', (req: Request, res: Response, next: Function) => {
    let handleError = () => {
        res.sendStatus(400);
    }

    let id = req.params.user_id;
    let note : Note = req.body.note;
    try {
        UserService.addNote(id, note);
        res.sendStatus(201);
    } catch(err) {
        handleError();
    }
})

export default router;

Con esto tendríamos nuestros endpoints pero sin ningún tipo de autenticación, con lo cual cualquier usuario podría publicar notas en nombre de otro.

Con esto ya tenemos nuestros modelos, nuestro servicio y los endpoints, ahora toca unirlo todo. Sobre la raíz del directorio del proyecto, crearemos el fichero «app.ts» donde cargaremos los módulos principales de nuestra aplicación y realizaremos la configuración de nuestra aplicación.

import {Request, Response} from "express";
var express = require('express');
var path = require('path');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');


import users from './endpoints/user';
import notes from './endpoints/notes';

var passportModule = require('passport');
import {Strategy, ExtractJwt, StrategyOptions} from 'passport-jwt'
import {UserService} from './services/user.service'
const app = express();

let apiVersion = 'v1'

app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/' + apiVersion + '/user', users);
app.use('/' + apiVersion + '/notes', notes);

// Configure passport js
let opts : StrategyOptions = {
    jwtFromRequest: ExtractJwt.fromAuthHeader(),
    secretOrKey: 'secret'
}

let passport = passportModule.use(new Strategy(opts, (jwtPayload, done) => {
    let user = UserService.findById(jwtPayload.sub);
    if(!user) {
        return done(null, false);
    } else {
        done(null, user);
    }
}))

// catch 404 and forward to error handler
app.use((req: Request, res: Response, next: Function) => {
  var err: any = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handlers
app.use(function(err: any, req: Request, res: Response, next: Function) {
    console.log(err.message);
    res.sendStatus(err.status || 500);
});

export default app;

Ahora tendríamos nuestra aplicación casi lista para funcionar. Hemos configurado express para utilizar los endpoint y hemos configurado passport.js para que utilice una estrategia de autenticación mediante JWT (si no conocéis el patrón Strategy ya estáis tardando en darle un repaso ;-)).

Fijaos que en la configuración de la estrategia le hemos dicho que utilice como clave ‘secret’ (por lo que más queráis, jamás utilicéis algo así como clave, esto es solo un ejemplo), y que extraiga el token de la cabecera (un poco más abajo retomaremos este tema). Uno de los parámetros es un callback. Este callback se llamará tras decodificar el JWT, y nos proporcionará el payload del JWT enviado. A partir del payload decodificado podemos obtener la información correspondiente al usuario y devolverla.

Por último, crearemos el fichero «main.ts», que será el punto de entrada a nuestra app e iniciara el server.

	/**
 * Module dependencies.
 */

var debug = require('debug')('mosway:server');
var http = require('http');
import app from './app';

/**
 * Get port from environment and store in Express.
 */

var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);

/**
 * Create HTTP server.
 */

var server = http.createServer(app);

/**
 * Listen on provided port, on all network interfaces.
 */

server.listen(port);
server.on('error', onError);
server.on('listening', onListening);

/**
 * Normalize a port into a number, string, or false.
 */

function normalizePort(val: any) {
  var port = parseInt(val, 10);

  if (isNaN(port)) {
    // named pipe
    return val;
  }

  if (port >= 0) {
    // port number
    return port;
  }

  return false;
}

/**
 * Event listener for HTTP server "error" event.
 */

function onError(error: any) {
  if (error.syscall !== 'listen') {
    throw error;
  }

  var bind = typeof port === 'string'
    ? 'Pipe ' + port
    : 'Port ' + port;

  // handle specific listen errors with friendly messages
  switch (error.code) {
    case 'EACCES':
      console.error(bind + ' requires elevated privileges');
      process.exit(1);
      break;
    case 'EADDRINUSE':
      console.error(bind + ' is already in use');
      process.exit(1);
      break;
    default:
      throw error;
  }
}

/**
 * Event listener for HTTP server "listening" event.
 */

function onListening() {
  var addr = server.address();
  var bind = typeof addr === 'string'
    ? 'pipe ' + addr
    : 'port ' + addr.port;
  debug('Listening on ' + bind);
}

Con esto ya tendriamos una versión básica de nuestra API sin emplear aún los JWT

6. Generación del token JWT

Vamos a refactorizar un poco nuestro código para generar y enviar el token JWT cuando el usuario realice login. Para ello modificaremos el código «endpoints/user.ts», de forma que antes de enviar el código de estado generemos y añadamos a las cabeceras el token JWT. En el payload utilizaremos el id del usuario como «subject». También convendría utilizar el campo «exp» para definir cuando expira el token, y comprobar que el token no ha expirado cuando se realiza la petición, pero para el propósito de este tutorial es suficiente con el subject.

if(user && (password === user.password)) {
    res.set('jwt', jsonwebtoken.sign({sub: user.id}, 'secret'));
    res.sendStatus(200);
}

Fácil, ¿verdad? Comentar que para ese ejemplo solo hemos añadido el ID del usuario como subject del payload del JWT. Podemos añadir cualquier información que creamos conveniente al payload, solo hay que tener cuenta que algunos de los campos (como iss (issuer), exp (expiration time), sub (subject), aud (audience) entre otros están reservados).

Como estamos utilizando gulp, al detectar cambios en el código se encargará de transpilar de nuevo y rearrancar el server. Si utilizando Postman o alguna herramienta similar realizamos ahora una petición POST contra nuestro servidor, veremos que además del código de estado nos devolverá en las cabeceras el token JWT.

POST /v1/user/login HTTP/1.1
Host: localhost:3000
Content-Type: application/json
{
    "username" : "test",
    "password" : "1234"
}

login

7. Autenticación

Ahora que nuestro mecanismo de login nos devuelve el token JWT, vamos a restringir el acceso al endpoint de notas. Para ello lo que debemos hacer es leer las cabeceras de la petición HTTP, comprobar si existe el campo «Authorization», extraer el token, decodificarlo, comprobar que la firma coincide, y si todos esos pasos han ido bien podemos asegurar que se trata de un usuario autenticado.

Por suerte passport.js nos proporciona un middleware para realizar la autenticación. Además de rechazar la petición con un código de estado 401 si la validación del JWT falla, este middleware también nos añade la información del usuario a la request, (recordemos que los middleware de Express.js están basados en el patron Chain Of Resposability, de forma que el siguiente eslabón de la cadena o middleware recibirá la información proporcionada por middleware de Passport.js). De esta forma, gracias al JWT podemos saber quién hace la petición. Vamos a refactorizar nuestro fichero «endpoints/notes.ts»

Lo primero que haremos será incluir el middleware de Passport.js a nuestro router, de tal forma que se ejecute para todas las llamadas al endpoint. Una vez hecho esto, ya no necesitaremos indicar el userID como parámetro de la petición HTTP puesto que se extrae del JWT, con lo cual nuestro endpoint quedaría así

import {UserService} from '../services/user.service';
import {Note} from '../models/note';

import {Request, Response} from "express";
var express = require('express');
var router = express.Router();
var passport = require('passport');

router.use(passport.authenticate('jwt', { session: false}))


/* GET home page. */
router.get('/', (req: Request, res: Response, next: Function) => {
    try {
        let notes = UserService.findById(req.user.id).notes;
        res.json({notes: notes});
    } catch(err) {
        res.sendStatus(400);
    }
});

router.post('/', (req: Request, res: Response, next: Function) => {
    let handleError = () => {
        res.sendStatus(400);
    }

    let note : Note = req.body.note;
    try {
        UserService.addNote(req.user.id, note);
        res.sendStatus(201);
    } catch(err) {
        handleError();
    }
})

export default router;

Con esto ya tendríamos securizado nuestro endpoint. Puesto que la firma del token se calcula en base al payload, si un usuario intentase recodificar su token para hacerse pasar por otro usuario la firma no sería valida y el servidor le denegaría el acceso.

post
get

8. Conclusiones

Mediante el uso de JSON Web Tokens se hace bastante sencillo implementar la autenticación de nuestras API rest, sobre todo si se compara con Oauth que requiere de un aprendizaje más profundo. En el caso de Express.js, junto con la librería Passport.js la implementación se hace verdaderamente sencilla.

Como principal desventaja, es que no existe manera de revocar un JWT, con lo cual se deben asignar tiempos de expiración bastante ajustados para evitar que en caso de que un token haya sido comprometido pueda ser utilizado de forma indefinida.

9. Referencias

http://expressjs.com
https://jwt.io/introduction
http://passportjs.org
https://www.npmjs.com/package/passport-jwt

1 COMENTARIO

  1. […] anteriores tutoriales vimos con securizar un API REST utilizando Node.js y JWT, en esta ocasion utilizaremos Spring Boot ya que nos permite desarrollar rápidamente API […]

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad