Node.jsFastifyJavaScript

Gatsby Webhook Service (3) - JWT Authentifizierung

Im letzten Beitrag haben wir ein Netzwerk aus zwei Container erstellt. Einen für Redis und einen für unseren Node.js Server, ...

image
... den wir jetzt anfangen werden zu programmieren. Dabei beschränken wir uns in diesem Teil allerdings auf das Initialisieren des Servers und der Authentifizierung des Clients.


Die Vorbereitung

Als erstes initialisieren wir das Projekt Package und installieren alle nötigen Abhängigkeiten.

~$ mkdir api && mkdir ./api/static && cd api
~$ yarn init && yarn add fastify fastify-cors fastify-formbody fastify-redis fastify-static dotenv got http-errors @mgcrea/pino-pretty-compact pino-pretty sharp

Alternativ zu yarn kann natürlich auch npm verwendet werden.

Das Fastify Fundament

Die Datei app.js soll den Einsprungpunkt der Anwendung darstellen und somit werden neben den benötigten Modulen, die wir zum Betrieb des Servers brauchen, auch einige Konstanten initialisiert und Standardeinstellungen geladen.

./api/app.js

/*
	In der .env ist das Geheimnis zum Verschlüsseln gespeichert
*/
require('dotenv').config();
/*
	Importieren die benötigten Module ...
*/
const path = require('path');
const { promises: fs } = require('fs');
const cluster = require('cluster');
const prettifier = require('@mgcrea/pino-pretty-compact');
const createFastifyInst = require('fastify');
const fp = require('fastify-plugin');
/*
	... und Routen
*/
const routes = require('./routes');
/*
	Initialisieren der globale Konstanten
*/
const external = {
  hostname: 'www.instagram.com',
  path: (arg) => `/${arg ? arg+'/' : ''}?__a=1`
};

const port = process.env.PORT ?? 3333;
const host = process.env.HOST ?? '0.0.0.0';    
/*
	Fastify instanzieren
*/
const fastify = createFastifyInst({ 
	logger: { 
        prettyPrint: true, 
        prettifier, 
    }
});
/*
	Die Cache-Lebenszeit
*/
const expire = 15 * 60;
/*
	Das Plugin für die JWT Authentifizierung
*/
fastify.register(require('./src/bearer'), {
  headerName: 'authorization',
  secret: process.env.SECRET,  
  salt: process.env.SALT,
  excluded: ['/api/access-token', '/api/instagram/xmalanderssein', '/api/static/']
});
/*
	Plugins laden
*/
fastify.register(require('fastify-redis'), {  
  host: process.env.REDIS,
  closeClient: true,
});

fastify.register(require('fastify-static'), {
  root: path.join(__dirname, 'static'),
  prefix: '/static'
});

fastify.register(require('fastify-cors'), {
  origin: true,
  method: ['GET', 'POST', 'PATCH', 'DELETE'],
  exposedHeader: ['token']
});

fastify.register(require('fastify-formbody'));
/*
	Redis wird ebenfalls zum Austausch von Variablen,
    die in allen Cluster-Threads benötigt werden, genutzt.
*/
fastify.register(fp(async (fastify, {}, done) => {
  const { redis } = fastify;
  let cookies = ''; // Ich habe hier meine Instagram Cookies als Default

  try {    
    cookies = await fs.readFile(path.join(__dirname, '/data/cookie'));      
  } catch(error) {
    fastify.log.error(error);
  } finally {
    await Promise.all([
      redis.set('isBuilding', 'false'),
      redis.set('cookies', cookies),
    ]);
  }
  
  done();
}));
/*
	Die einzelnen Routen registrieren
    
    Wir beginnen hier mit dem generieren des Bearer Token
*/
fastify.register(routes.access);
// fastify.register(routes.gatsby);
// fastify.register(routes.instagram, { ...external, expire });
// fastify.register(routes.private);
          
if (cluster.isMaster) {        
  const nCpus = (require('os')).cpus().length;
  fastify.log.info(`Master ${process.pid} is running`);       
	
  // Für jeden CPU Kern einen Worker forken.
  for (let i = 0; i < nCpus; i++) 
    	cluster.fork();        

  cluster.on('online', (worker) => 
    fastify.log.info(`Worker ${worker.process.pid} is listening`));
    
  // Nach einem Absturz wird ein neuer Worker gestartet.
  cluster.on('exit', (worker, code) => {
	fastify.log.error(`Worker ${worker.process.pid} died`);
	if (code === 1) setTimeout(cluster.fork, 10000);
  });  
} else {	     
  // Worker
  try {       
    (async () => {          
      await fastify.listen(port, host);
    })();
  }
  catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
}

Authentifizierung

Bei der JSON Web Token Authentifizierung werden verschlüsselte Benutzerdaten im JSON Format beim Login zum Client gesendet. Der Client fügt dem Header jedes Requests ein zusätzliches Feld hinzu, welches das verschlüsselte Token als Wert hat.

./api/src/bearer.js

/*
  Module laden
*/
const fp = require('fastify-plugin');
const err = require('http-errors');
const { promisify } = require('util'); // Ein Promise Wrapper
const crypto = require('crypto');
/*
  Methoden denen als letzten Parameter eine Callback Funktion, die sich
  an Node.js Best Practices hält, übergeben wird, werden in ein Promise
  Objekt gewrapped und dieses wird dann von promisify() zurückgegeben.
*/
const scryptAsync = promisify(crypto.scrypt); 
const randomBytesAsync = promisify(crypto.randomBytes);

module.exports = fp(async function(fastify, options , next) {	
  if (options.secret === undefined)
    throw new Error('Expecting a secret');

  if (options.secret.length < 20)
    throw new Error('Secret needs a minimum length of 20 chars');
  
  if (options.salt === undefined)
    throw new Error('Expecting a salt');

  const opts = {		
    expireTime: 0x1B7740, // 1h	
    interval: 0x1B7740,
    headerName: 'Token',
    excluded: [],		
    ..options,
  };	
  
  /*
    Dieses Map Objekt dient als Cache damit nicht bei jeder Anfrage das 
    Token zur Validierung entschlüsselt werden muss. Das Token selbst ist
    der Schlüssel und der Wert sind die dazugehörigen entschlüsselten 
    Daten. 
  */
  const cache = new Map();
  
  /*
    Hier wird der Schlüssel für die krypthographischen Methoden 
    deklariert und initialisiert.
  */
  const key = await scryptAsync(opts.secret, opts.SALT, 32);
  
  /*
    Diese Methode wird nach dem erfolgreichen authentifizieren 
    aufgerufen und erstellt das Token. Das Token wird dann zum 
    Client gesendet, welcher dieses dann jedem Request dem
    Header anhängt.
  */
  async function createToken(data) {		
    if (!data || Object.keys.length < 1) 
      throw new Error('No data to encrypt');

    const token = 'Bearer ' + await encrypt(key, JSON.stringify(data));
    
    data.expires = Date.now() + opts.expireTime;
    cache.set(token, data);

    return token;
  }
  
  /*
    Nach einem festgelegten Interval sollen abgelaufe Token gelöscht werden.
  */
  function removeExpired(value, key, map) {
    const time = Date.now();

    if (value.expires < time) {
      map.delete(key);
    }	
  }

  setInterval(() => cache.forEach(removeExpired), opts.interval);

  /*
    Diese Methode wird vor dem Ausführen der privaten Route
    Methoden ausgeführt und überprüft ob ein gültiger Token
    vorhanden ist.
  */
  async function validation(req, reply) { 	
    for (let exluded of opts.excluded) {
      if (req.url.startsWith(exluded))
        return;        
    }

    let token = req.headers[opts.headerName];
    /*
      Wenn das Token gecached ist, sollte data nun die entschlüsselten 
      Benutzerdaten beinhalten. Andernfalls: data === undefined.
    */
    let data = cache.get(token);
		
    try {									
      const time = Date.now();				
      /*
        Bei Nutzung eines Reverse Proxys sind die korrekten Einstellungen 
        nötig. Ansonsten ist req.ip immer localhost.                
      */
      if (data !== undefined && data.ip === req.ip && data.expires > time) {
        req.user = data;
      } else {	
        /*
          Fall das Token gecached ist und es abgelaufen ist, soll ein neues
          erstellt werden. Das wird dann unter dem Headerfeld "new-token" 
          dem Header hinzugefügt
        */
        if (data !== undefined)	
          cache.delete(token);
          
          data = await decrypt(key, token.substring(7));			        
          token = await createToken(key, data);
          
          reply.header('new-token', token);
        
          req.user = data;
        }						
      } catch (error) {
        /*
          Der Catch-Block wird in der Regel ausgelöst, wenn decrypt()
          einen Fehler wirft. 
        
          Es wird dann wenn nötig das Token aus dem Cache gelöscht und ein
          Error Objekt geworfen, das Fastify zum senden von einen 403 Error
          triggert.
        */
      if (data !== undefined)	
        cache.delete(token);

      throw new err.Forbidden();
    }	
  }
  /*
    Zum Verschlüsseln wird AES 256 Bit im Cipher block chaining Mode
    verwendet. Der Initialisierungsvektor sind die ersten 16 Byte des
    Tokens. Die verschlüsselte Nachricht sind alle folgenden Bytes.
  */
  async function decrypt(key, data) {
    if(!data || data.length < 32 || data.length % 2 !== 0)
      throw new Error('Invalid data');
	/*
      Da der String Hexadezimal codiert ist, entspricht 1 Byte 2 
      Zeichen. Für das untere Bespiel daran denken das 8 Bit ein 
      Byte sind.
      
      Bsp.: 1111 1111 = FF = 255 
    */
    const iv = Buffer.from(data.substring(0, 32), 'hex');
    const encrypted = data.substring(32, data.length);
    const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
    /*
      Dem chunks Array wird nach dem der Cipher Stream bereit zum 
      Lesen ist der aktuelle Lesepuffer angehangen. Nicht verwirren 
      lassen. Trotz des Promises blockiert diese Lösung den Hauptprozess.
    */
    const chunks = [];

    return new Promise((resolve, reject) => {
        decipher.on('readable', () => {
          let chunk;
          while (null !== (chunk = decipher.read())) 
            chunks.push(chunk.toString('utf8'));
        });
		
        decipher.on('end', () => resolve(JSON.parse(chunks.join(''))));
        decipher.on('error', reject);
		/*
          Die verschlüsselte Nachricht wird in den Stream Cipher
          geschrieben.
        */
        decipher.write(encrypted, 'hex');
        decipher.end();
    });
  }
  /*
    Das Verschlüsseln funktioniert ganz ähnlich zum Entschlüsseln.
    Nur das dem chunks Array der Hexadezimalwert des Buffers angehangen
    und der zu verschlüsselnde Inhalt in den Stream geschrieben wird.
    Der vom aufgelösten Promise Objekts zurückgegebene Wert ist der 
    Initialisierungsvektor + verschlüsselte Nachricht. Der IV ist nebenbei
    erwähnt eine von RAND_bytes() (OpenSSL Lib) erzeugte zufällige Abfolge
    von 16 Byte.
  */
  async function encrypt(key, data) {
    const iv = await randomBytesAsync(16);
    const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
    const chunks = [];
		
    return new Promise((resolve, reject) => {
      cipher.on('readable', () => {
        let chunk;
        while (null !== (chunk = cipher.read())) 
          chunks.push(chunk.toString('hex'));
        });
		
		cipher.on('end', () =>  resolve(iv.toString('hex') + chunks.join('')));
            
        cipher.on('error', reject);
		
        cipher.write(data);
        cipher.end();
      });
    }
  /*
    fastify.requireAuthentication() in allen Routen Modulen ausgeführt
    denen vor der Ausführung die Methode validation() ausgeführt werden
    soll. 
    
    fastify.createToken() wird nach der Benutzerauthentifizierung
    ausgeführt.
  */
  const requireAuthentication = () => {
    fastify.addHook('preHandler', validation);
  }
  
  fastify.decorate('requireAuthentication', requireAuthentication);
  fastify.decorate('createToken', createToken);
	
  next();
});

./api/routes/access-token.js

require('dotenv').config();

const path = require('path');
const { createHmac, timingSafeEqual } = require('crypto');

const users = new Map(require(path.join(__dirname + '/../data/users.json')));

module.exports = async function(fastify) {
  /*
    Die Route /api/access-token mit POST als Method wird mit folgender 
    Methode in der Fastify Instanz registriert.
  */
  fastify.post('/api/access-token', async (req, res) => {
    /*
      Wenn im Header des Client Request der Content Type auf application/json 
      gesetzt ist, kümmert sich Fastify direkt um das Parsen zum Objekt.
      
      Die Eigenschaften email und password werden dem body Objekt extrahiert.
    */
    const { email, password } = req.body;
    /*
      Alternativ kann man auch ein Error Objekt aus dem http-errors Modul
      werfen.
    */
    const sendFailure = () => res.status(403)
                                .send({ result: 'authorization failed' });

    if (!users.has(email)) 
      return sendFailure();

    const user = users.get(email);
    /*
      Aus dem gesendeten Passwort wird ein HMAC Hashwert mittels SHA mit 
      512 Bit erzeugt. 256 Bit sollten auch *noch* genügen.
    */
    const _password = createHmac('sha512', process.env.SECRET)
                      .update(password + user.salt)
                      .digest('hex');
    /*
      Durch timingSafeEqual() aus dem crypto Modul werden auf Timing
      gestützte Angriffsvektoren verhindert.
      
      Andernfalls wäre es Möglich durch Messung der Antwortzeit das 
      Passwort zu erraten, da je nach Position des ungleichen Zeichens
      die Prüfung eine unterschiedliche Zeit in anspruch nimmt. Die
      Methode timingSafeEqual() gleicht die Dauer des Prüfvorgangs an.
    */
    if (!timingSafeEqual(Buffer.from(_password), Buffer.from(user.password))) 
      return sendFailure();

    const bearer = await fastify.createToken({ email });

    res.send({ bearer });
  });
}

./api/routes/index.js

module.exports = {
  access: require('./access-token'),
  // gatsby: require('./gatsby'),
  // instagram: require('./instagram'),
  // private: require('./private'),
};

Weitere Blog Posts aus dieser Serie

  1. Gatsby Webhook Service
  2. Gatsby Webhook Service (2) - Das große Containern
  3. Gatsby Webhook Service (3) - JWT Authentifizierung
  4. Gatsby Webhook Service (4) - Instagram
  5. Gatsby Webhook Service (5) - Gatsby