... 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
Gatsby Webhook Service
Gatsby Webhook Service (2) - Das große Containern
Gatsby Webhook Service (3) - JWT Authentifizierung
Gatsby Webhook Service (4) - Instagram
Gatsby Webhook Service (5) - Gatsby