Backend NodeJS

Compétences visées

  • compréhension des protocoles HTTP, Websocket, REST
  • déploiement d’une application avec pm2 et Nginx
  • usage intermédiaire d’Express
  • usage d’un serveur d’applications (FeatherJS)
  • échange d’information temps-réel avec les websockets
  • compréhension du flot d’authentification
  • [usage d’un moteur de template (pug)]

Structure générale des applications web et mobiles

Avec le protocole HTTP

  1. Le client (navigateur) fait une requête d’accès à une ressource auprès d’un serveur Web selon le protocole HTTP
  2. Le serveur vérifie la demande, les autorisations et effectue la tache demandée. Il envoie en retour une réponse au client.
  3. Le client interprète la réponse reçue (ex: modification de l’affichage)

Il faut noter que le serveur ne peut pas envoyer d’information à un client spontanément, c’est à dire sans que celui-ci ne lui ait fait une demande.

Avec le protocole Websocket

Le client fait des demandes au serveur, mais le protocole websocket permet également à un serveur d’envoyer des informations de son propre chef à un ou plusieurs de ses clients connectés.
Les connexions entre client et serveur par un websocket sont persistantes, par opposition aux connexions http ordinaires.

Sites web statiques

Les premiers sites web étaient entièrement statiques. Il en existe encore beaucoup, notamment pour des documentations. Ici les ressources accédées sont toutes statiques : pages html, images, vidéos, feuilles de style (css). Aucun script n’est exécuté, le navigateur affiche les éléments statiques html, css etc. et permet la navigation entre les pages en cliquant sur les liens hypertexte.

L’arborescence des fichiers html définit la structure des urls.

Par exemple, l’accès à http://monsite.org/collections/hiver/manteaux.html provoquera un accès en lecture à un fichier situé à l’emplacement relatif collections/hiver/manteaux.html par rapport à la racine de l’emplacement des pages statiques, typiquement /var/www/monsite/html/

Applications web dynamiques

Deux éléments peuvent rendre un site web dynamique :

  • du code javascript peut être ajouté aux pages html, permettant l’exécution de fonctions par le moteur javascript du navigateur.
  • les requêtes du client vers le serveur peuvent être des commandes vers un serveur d’application, qui peut faire des accès à une base de données et/ou calculer dynamiquement une réponse au navigateur

Les protocole HTTP et Websocket

Protocole HTTP : requêtes et réponses

Une requête HTTP est un flux d’octets (un texte) envoyé par le client vers le serveur au travers du réseau. Elle est structurée en lignes.

Exemple d’une requête GET à l’url https://yesno.wtf/api :

GET /api HTTP/1.1
Host: yesno.wtf
User-Agent: curl/7.64.1
Accept: */*

En retour, le serveur renvoie une réponse à cette requête, avec une structure analogue.

Exemple de réponse à la requête précédente :

Status: 200 OK
Transfer-Encoding: chunked
X-Powered-By: Phusion Passenger 5.0.1
access-control-allow-origin: *
access-control-request-method: *
cache-control: max-age=0, private, must-revalidate
etag: "c869ad1693dfe675a79a187fa6c1020e"
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-request-id: a6d669c1-401d-4355-aa9a-9dfcd97859a3
x-runtime: 0.002400
x-xss-protection: 1; mode=block

{
    "answer": "no",
    "forced": false,
    "image": "https://yesno.wtf/assets/no/32-b62f1f8058c1d7f06c528319faccfb38.gif"
}

Les requêtes sont souvent envoyées par les navigateurs web :

  • obtention du contenu d’une page
  • obtention du contenu d’un fichier de script (js) ou de style (css)
  • post des données d’un formulaire
  • appel d’un webservice, sur le serveur d’origine ou sur un autre serveur (cross-origin)

Elles peuvent aussi être envoyées par tout type de programme ou d’application.

Principaux headers

Headers de la requête

  • Content-Type, type du média du body de la requête

    Content-Type: application/json
    Content-Type: application/json; charset=UTF-8
    Content-Type: text/html
  • Accept indique quels sont les types de contenu, exprimés sous la forme de types MIME, que le client sera capable d’interpréter

Headers de la réponse

  • Content-Type, type du média du body de la réponse

    Content-Type: application/json
    Content-Type: text/html
  • ‘ETag’

    Etag: "chaine de hashage unique"

    Si un ETag est présent dans la réponse, le navigateur ira toujours requêter le serveur pour vérifier qu’il a la dernière version. Si le navigateur possède une copie de la ressource en cache et si l’ETag sur le serveur est le même que celui du cache, le body de la ressource ne sera pas requêté et c’est celui du cache qui sera utilisé. S’il est différent, le navigateur chargera son cache avec le nouveau body et mettra à jour l’ETag. Cette stratégie est nécessaire pour les applications SPA dont le point de départ est le fichier index.html, afin que le déploiement d’une nouvelle version soit immédiatement disponible par les utilisateurs sans qu’ils aient besoin de faire un reload ou un hard-reload.

Le header ETag est prioritaire sur le header Cache-Control

  • Cache-Control

Une directive Cache-Control indique au navigateur de ne même pas chercher à charger une version plus récente d’une ressource avant une certaine date.

Cache-Control: "no-cache, no-store, max-age=0, must-revalidate"

Cookies

Un cookie est une paire (clé, valeur) qui est stockée dans un espace dédié du navigateur, associé au nom de domaine appelé.

Un serveur peut mettre dans une response HTTP un ordre de stockage d’un nouveau cookie dans le navigateur client :

Set-Cookie: token=JjlmIyAZuV6u6HY4R8Vl8byOhvkkLzZDTcgTD7Paehe3xLy4VI; mode=dev;

Lors de chaque requête sur le même nom de domaine, le navigateur inclut systématiquement tous les cookies dans la requête HTTP, dans une ligne (attention à l’espace après ;) :

Cookie: key1=value1; key2=value2;

Exemple:

GET /spec.html HTTP/1.1
Host: www.example.org
Cookie: token=JjlmIyAZuV6u6HY4R8Vl8byOhvkkLzZDTcgTD7Paehe3xLy4VI; mode=dev; 
--cookie "key=value"

Activité à réaliser

  • Utiliser les devtools du navigateur pour examiner le contenu des requêtes et réponses HTTP, les cookies
  • Utiliser l’option -v de la commande curl pour examiner le contenu des requêtes et réponses HTTP

Le protocole Websocket

C’est un protocole de communication bi-directionnel sur une connection TCP. Dans une communication par websocket il y a toujours un serveur et un client comme dans le protocole HTTP, mais le serveur peut à tout moment envoyer des données vers le client, sans qu’il s’agisse d’une réponse à une demande.
Les connexions entre un serveur et un client sont persistantes, contrairement aux connexions http. Un serveur peut être connecté à un grand nombre de clients

Un client connecté à un serveur peut lui envoyer des messages, et il est prévenu par des événements de l’arrivée de messages en provenance du serveur. Un serveur peut émettre des événements à un client particulier, ou globalement à tous les clients connectés (broadcast).

Activité à réaliser

Utiliser les devtools du navigateur pour examiner le contenu des messages websocket de https://web.whatsapp.com/

Spécification REST

Elle s’appuie sur la notion de ressource, une ressource étant associée à un endpoint.

Exemple : la ressource users associée au end-point http://json-server.jcbuisson.dev/users

  • chaque instance de ressource (par ex. chaque user) possède un identifiant, généralement autogénéré par la base de données
  • les verbes HTTP GET, POST, PUT et DELETE correspondent à des opérations de type CRUD (CREATE, READ, UPDATE, and DELETE) sur les ressources
  • cette spécification est applicable aussi bien au protocole HTTP qu’au protocole Websocket

GET

La méthode GET est utilisée pour obtenir du serveur la liste des toutes les ressources d’un certain type, ou une ressource particulière précisée par son identifiant

Exemples :

GET http://json-server.jcbuisson.dev/users 
GET http://json-server.jcbuisson.dev/users/89
GET http://json-server.jcbuisson.dev/users?gender=male

Les deux requêtes font référence au end-point http://json-server.jcbuisson.dev/users ; la première demande la liste de tous les utilisateurs, la seconde la liste de tous les utilisateurs hommes, la troisième l’utilisateur d’identifiant 89.

curl http://json-server.jcbuisson.dev/users

POST

La méthode POST est utilisée pour créer une nouvelle ressource sur le serveur

Exemple :

POST http://json-server.jcbuisson.dev/users

Cette requête créée une nouvelle resource pour le endpoint http://json-server.jcbuisson.dev/users, c’est à dire un nouvel utilisateur. Les données qui concernent l’utilisateur doivent être placées dans la section DATA de la requête.

curl -X POST -H 'Content-Type: application/json' -d '{"firstname": "John", "lastname": "Doe"}' http://json-server.jcbuisson.dev/users

PUT

La méthode PUT est utilisée pour mettre à jour une ressource spécifique sur le serveur

Exemple :

PUT http://json-server.jcbuisson.dev/users/89

L’ensemble des données de l’utilisateur 89 doit être placé dans la section DATA de la requête.

curl -X POST -H 'Content-Type: application/json' -d '{"firstname": "Jon", "lastname": "Doe"}' http://json-server.jcbuisson.dev/users/1

PATCH

La méthode PATCH est également utilisée pour mettre à jour une ressource spécifique sur le serveur

Exemple :

PATCH http://json-server.jcbuisson.dev/users/89

À la différence de PUT, seules les données à modifier doivent être placées dans la section DATA de la requête

curl -X PATCH -H 'Content-Type: application/json' -d '{"firstname": "Joe"}' http://json-server.jcbuisson.dev/users/1

DELETE

La méthode DELETE permet de supprimer une ressource spécifique Example:

DELETE http://json-server.jcbuisson.dev/users/1
curl -X DELETE http://json-server.jcbuisson.dev/users/1

OPTIONS

OPTIONS est utilisée pour obtenir les options de communication avec la cible : verbes HTTP possibles, méthodes d’authentification demandées, utilisation de la politique CORS (Cross Origin Resource Sharing)

Déploiement d’un site statique sur un serveur Nginx

Fichier de configuration

# /etc/nginx/sites-available/mathieu.dufullstack.fr

server {
    listen 80;
    server_name mathieu.dufullstack.fr www.mathieu.dufullstack.fr;

    location / {
         root /home/mathieu;
    }
}

Activation

ln -s /etc/nginx/sites-available/mathieu.dufullstack.fr /etc/nginx/sites-enabled
service nginx reload

En cas d’erreur de syntaxe, taper nginx -t

Virtual Host par défaut

L’accès à dufullstack.fr ou à xxx.dufullstack.fr pour une valeur de xxx non définie dans les configurations, conduit à servir un des virtual hosts par défaut.

Le virtual host par dėfaut est celui dont le nom est le premier dans l’ordre lexicographique dans /etc/nginx/sites-enabled. Souvent, on créera un virtual host par dėfaut de nom 000_default

Activité à réaliser

  • chaque étudiant déploie sa page personnelle statique sous forme d’un virtual host sur dufullstack.fr
  • créer un virtual host par défaut

Express

Express est le framework serveur web le plus utilisé pour NodeJS. Il facilite la construction d’applications structurées en microservices.

Installation

$ npm install express

Hello World

const express = require('express')
const app = express()

app.get('/foo', function (request, response) {
  response.send('Hello Foo!')
})

app.get('/bar', function (request, response) {
  response.send('Hello Bar!')
})

app.listen(3000, function () {
  console.log("Server listening on port 3000")
})

Production de la réponse

res.send(val)

L’appel res.send(val) envoie la réponse val dans la réponse res et ferme la connexion :

  • si val est string, le Content-Type de la réponse est mis à text/html
  • si val est object ou array, le Content-Type de la réponse est mis à application/json et val est stringifié
  • le Content-Length de la réponse est affecté
  • le status de la réponse est mis à 200
  • response est automatiquement fermée

res.status(code)

L’appel response.status(code) affecte le status de response à code et renvoie l’objet response modifié, pour chaînage. Exemple :

response.status(500).send('Erreur inconnue')

res.sendStatus(code)

res.sendStatus(200) // equivalent to res.status(200).send('OK')
res.sendStatus(403) // equivalent to res.status(403).send('Forbidden')
res.sendStatus(404) // equivalent to res.status(404).send('Not Found')
res.sendStatus(500) // equivalent to res.status(500).send('Internal Server Error')

res.sendFile(path)

Transfère le fichier path en assignant au header Content-Type de la requête, le mime-type associé à l’extension du fichier transféré

res.redirect(path)

Redirige vers la route path

Headers

Ajouter un header :

res.set('Content-Type', 'application/json')
res.set('Cache-Control', 'max-age=3600, must-revalidate')
res.cookie(<name>, <value>[, <options>])

ex :

res.cookie('prefs', 'video-games,books')

Les cookies sont ensuite ajoutés à chaque requête ultérieure du navigateur.

Extraction des query params

Ils sont directement accessibles sous forme d’un dictionnaire dans request.query

const express = require('express')

const app = express()

app.get('/api/pages', function (request, response) {
  let queryParams = request.query
  console.log('queryParams', queryParams)
  //...
  response.send(...)
})

const port = process.env.PORT || 3000

app.listen(port, function () {
  console.log(`app listening on port ${port}`)
})

Test :

$ curl http://localhost:3000/api/pages?a=1&b=azer

Déploiement d’une application express sur un serveur Nginx

Démonisation du programme serveur avec PM2

PM2 est un process manager pour applications nodejs

$ sudo npm install -g pm2

Pour lancer le processus serveur et l’ajouter à la liste des processus gérés par PM2 :

$ pm2 start app.js
...
[PM2] Spawning PM2 daemon with pm2_home=/Users/chris/.pm2
[PM2] PM2 Successfully daemonized
[PM2] Starting /Users/chris/workspaces/FULLSTACK/node-server/server.js in fork_mode (1 instance)
[PM2] Done.
┌──────────┬────┬──────┬───────┬────────┬─────────┬────────┬─────┬──────────┬───────┬──────────┐
│ App name │ id │ mode │ pid   │ status │ restart │ uptime │ cpu │ mem      │ user  │ watching │
├──────────┼────┼──────┼───────┼────────┼─────────┼────────┼─────┼──────────┼───────┼──────────┤
│ app      │ 0  │ fork │ 21716 │ online │ 0       │ 0s     │ 0%  │ 9.2 MB   │ chris │ disabled │
└──────────┴────┴──────┴───────┴────────┴─────────┴────────┴─────┴──────────┴───────┴──────────┘
Use `pm2 show <id|name>` to get more details about an app

Visionnage en continu des logs :

$ pm2 log <id>

Configuration d’un VirtualHost pour Nginx

# /etc/nginx/sites-available/myapp.dufullstack.fr

server {
    listen 80;
    server_name myapp.dufullstack.fr www.myapp.dufullstack.fr;

    # static assets (images, videos, etc.)
    location /static {
        add_header Access-Control-Allow-Origin *;
        root /var/www/static;
    }

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

Activité à réaliser

  • créer une application qui affiche la charge cpu du serveur. Un bouton ‘update’ permet de rafraichir l’information. La déployer sur http://cpu.dufullstack.fr
  • créer une application qui reproduit le fonctionnement de yesno.wtf. La déployer sur http://yesno.dufullstack.fr

Middlewares

Une application express est une succession ordonnée d’appels de fonctions middleware, qui transforment progressivement response à partir de request :

  • middlewares d’application
  • middlewares de routage
  • middleware de traitement d’erreurs
  • etc.

app.use(<middleware>)

Exemple :

let logger = (req, res, next) => {
  console.log('request', req)
  next()
}
app.use(logger)

app.use(<route>, <middleware>)

Exemple : app.use('/api/users', middlewareFunction)

  • capture /api/users, mais aussi /api/users/<id>, etc.
  • capture tous les verbes GET, POST, etc.

Exemple : app.use('/api/users/:id', middlewareFunction)

  • l’argument id est accessible dans request.params.id

Middleware avec paramètres

let middleware = (param1, param2) => (req, res, next) => {
   ...
}
app.use(middleware(p1, p2))

Middlewares de parsing du body

express.json() etc. ajoutent un attribut body à request contenant le contenu parsé du corps (body) de la requête, en fonction du Content-Type spécifié dans les headers

// Content-Type: application/x-www-form-urlencoded
app.use(express.urlencoded({ extended: true }))

// Content-Type: application/json
app.use(express.json())

// Content-Type: html/text
app.use(express.text())

Test :

$ curl -X POST -d "azer" -H "Content-Type: text/plain" http://localhost:3000/api/pages?path=/my/page

Middleware de gestion des fichiers statiques à partir du système de fichiers du serveur

const express = require('express')
const app = express()

app.use(express.static('/var/www/html/myapp'))
app.listen(3000, () => console.log('running'))

Une requête GET /aaa/bbb/ccc.x conduit à servir le fichier statique /var/www/html/myapp/aaa/bbb/ccc.x

app.use('/public', express.static(path.join(__dirname, 'public')))

Une requête GET /public/index.html conduit à servir le fichier statique <dirname>/public/index.html

Middleware d’ajout de headers CORS

Par défaut les API XMLHttpRequest et Fetch utilisées par les navigateurs interdisent les requêtes vers une destination différente de l’origine (nom de domaine ET numéro de port). Toutefois, ces requêtes sont possibles avec l’accord du serveur. Pour cela, avant d’interdire une requête non-CORS, le client envoie une requête OPTION vers le serveur afin de demander son accord.

Le middleware cors permet d’autoriser de telles requêtes en ajoutant des headers spécifiques.

// allows CORS requests for all origins
app.use(cors())

Middleware de gestion des cookies

Le middleware cookie-parser extrait les cookies présents dans la requête et les met dans l’attribut req.cookies sous forme d’un dictionnaire (cookie_name, cookie_value)

app.use(cookie-parser())

Middleware de gestion des bearer tokens

const bearerToken = require('express-bearer-token')
...
app.use(bearerToken())

Après que la requête soit passée au travers du middleware bearerToken, le token est attaché à l’attribut .token de la requête.

Ce token peut être situé à différents endroits :

  • dans le header Authorization: Bearer <token> (le plus fréquent)
  • clé access_token dans req.body
  • clé access_token dans req.query
  • (Optional) cookie avec la clé access_token

Helmet

Ajoute des headers qui implémentent de bonnes pratiques de sécurité

app.use(helmet())

Activité à réaliser

  • créer une application qui présente un formulaire nom/prénom/date de naissance, et qui affiche le json associé dans la console du backend à chaque validation
  • compléter l’application pour que les utilisateurs soient enregistrés dans une base NeDB

Fonctions et librairies de requêtes HTTP

NodeJS (back-end)

Plusieurs librairies existent, basées sur le package http.

Exemple avec la librairie axios :

let response = await axios({
   url: USERS_ENDPOINT,
   method: 'post',
   headers: {
      "Authorization": `Bearer ${sessionToken}`,
      "Content-Type": "application/json",
   },
   data: userData,
})
let createdUser = response.data
...

Javascript (front-end)

Les requêtes http effectuées depuis un script par le navigateur sont appelées requêtes XHR (XML HTTP Request). On les effectue avec la fonction built-in fetch.

Exemple :

let response = await fetch(USERS_ENDPOINT, {
   method: 'POST',
   headers: {
      "Content-Type": "application/json",
   },
   body: userData,
})
let createdUser = await response.json()

Activité à réaliser

  • créer et déployer une application qui saisit le nom d’une ville et en affiche la température

Feathers

Feathers est une librairie légère, utilisable principalement côté serveur, mais également côté client.

Feathers opère sur les aspects suivants :

  • les opérations back-end, qui sont abstraites sous forme de services
  • des connecteurs vers toutes les bases de données, sous forme de services REST prédéfinis
  • l’authentification
  • le transport entre client et serveur peut être http ou websocket, par simple configuration et sans modification du reste du code
  • le temps-réel, grace à un mécanisme de type publish/subscribe (transport par websocket)
  • une librairie client simplifie l’accès aux services par le front-end

Exemple d’application temps-réel : liste partagée

Clonez le projet : https://gitlab.com/buisson31/fs-feathers-items, branche dom-api

backend/src/app.js

const feathers = require('@feathersjs/feathers')
const socketio = require('@feathersjs/socketio')
const memory = require('feathers-memory')

// Create a Feathers application
const app = feathers()

// Configure the Socket.io transport
app.configure(socketio())

// Create a channel that will handle the transportation of all realtime events
app.on('connection', connection => app.channel('everybody').join(connection))

// Publish all realtime events to the `everybody` channel
app.publish(() => app.channel('everybody'))

// Create an 'items' service which stores the items in memory (instead of a database)
app.use('items', memory())

// Start the server on port 3030
app.listen(3030)
console.log('listening on port 3030')

frontend/index.html

<!DOCTYPE html>
<html>
   <head>
      <meta charset="UTF-8">
      <title>Liste partagée</title>
   </head>
   <body>
      <h2>Liste de courses partagée</h2>

      <label>Item
         <input type="text" />
      </label>
      <button type="submit">Ajouter</button>

      <hr/>

   </body>
</html>

<script type="module">
import io from 'socket.io-client'
import feathers from '@feathersjs/client'

// Create a websocket connecting to Feathers server
const socket = process.env.NODE_ENV === 'production' ? io() : io('localhost:3030')
const app = feathers()
app.configure(feathers.socketio(socket))

function addItemElement(item) {
   const itemElement = document.createElement('li')
   itemElement.innerText = item.text
   document.body.appendChild(itemElement)
}

// get & display already existing items
app.service('items').find({}).then(itemsList => {
   itemsList.forEach(item => addItemElement(item))
})

// Listen to new items being created
app.service('items').on('created', item => {
   console.log('item created!', item)
   addItemElement(item)
})

const input = document.querySelector('input')
const addButton = document.querySelector('button[type="submit"]')

addButton.addEventListener('click', ev => {
   // Create a new item
   app.service('items').create({ text: input.value })
   input.value = ''
})
</script>

Activité à réaliser

Utiliser les devtools de Firefox pour visualiser le contenu des messages échangés au travers de la connexion websocket persistante

Déploiement d’une application FeathersJS avec transport websocket sur un serveur Nginx

server {
    listen 80;
    server_name myapp.fr www.myapp.fr;

    location = /favicon.ico { access_log off; log_not_found off; }

    # default location - for directives 'location /' and 'location /index.html'
    root /home/chris/workspaces/myproject/dist;

    # static assets (images, videos, etc.)
    location /static {
        add_header Access-Control-Allow-Origin *;
        root /var/www/static;
    }

    location /index.html {
        # because the path to /index.html never changes, ETag is used to leverage browser-side caching
        etag on;
    }

    location / {
        # js, css etc. static files other than /index.html contain fingerprints (hashes) in their filenames
        # so ETag is not needed. Cache them for 5 years
        etag off;
        add_header Cache-Control "max-age=315360000, immutable";

        # for history mode vue app, all non-static requests should be redirected to index.html
        try_files $uri $uri/ /index.html;

    }

    # feathersjs websocket route
    location /socket.io {
        proxy_pass http://localhost:3030;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

Services

Toutes les taches du back-end sont abstraites sous la forme de services.

Un service est un point d’accès (endpoint) REST, qu’on utilise un transport HTTP ou Websocket.

Implémentation

Un service est une simple classe possédant une ou plusieurs des méthodes suivantes :

class MyService {
   async create(data, params)
   async get(id, params)
   async find(params)
   async update(id, data, params)
   async patch(id, data, params)
   async remove(id, params)
   setup(app, path)
}

Dans l’exemple, le service ‘items’ s’appuie sur l’adapter feathers-memory qui permet un stockage de type REST en mémoire (non persistant).

Activité à réaliser

Ajouter (et gérer) un bouton ‘supprimer’ en face de chaque item dans l’interface web. On utilisera la méthode REST ‘remove’ du service ‘items’.

Services de bases de données

FeathersJS permet en quelques lignes de code de créer un service qui fournit un accès REST sur une table de base de données relationnelle, ou une base NoSQL. FeathersJS dispose d’adapters pour la plupart des SGBD (DBMS) existants.

Dans ce qui suit on va utiliser le query-builder Knex avec l’adapter feathers-knex

On utilisera aussi le système de configuration de feathers, qui prend les constantes dans un répertoire config

Installation de PostgreSQL sur Linux (debian)

apt install postgresql
sudo su postgres
psql # CLI de postgres, cheatsheet: https://gist.github.com/Kartones/dd3ff5ec5ea238d4c546
# \password postgres
-> "motdepasse" x 2
# createdb fs-feathers-items
<Ctrl-d>

backend/config/default.json

{
   "dbConfig": {
      "client": "pg",
      "connection": "postgres://postgres:motdepasse@localhost:5432/fs-feathers-items"
   }
}

backend/src/knex.js

const knex = require('knex')

module.exports = function (app) {
   const { client, connection } = app.get('dbConfig')
   const db = knex({ client, connection })

   app.set('knexClient', db)
}

backend/src/services/database/items/items.model.js

module.exports = async function(app, tableName) {
   try {
      const db = app.get('knexClient')
   
      await db.schema.createTable(tableName, table => {
         table.increments('id')
         table.timestamp('created_at').defaultTo(db.fn.now()) // ex: 2020-11-23 06:32:48.524937+01
         table.text('text').notNull()
      })
      console.log(`Created ${tableName} table`)
   } catch(err) {
      console.log(`Error creating ${tableName} table: ${err.toString()}`)
   }
}

backend/src/services/database/items/items.service.js

const service = require('feathers-knex');

module.exports = function(app) {
   const db = app.get('knexClient')
   let name = 'items'

   app.use(name, service({
      Model: db,
      name,
      paginate: false,
   }))
}

backend/src/scripts/create-tables.js

const feathers = require('@feathersjs/feathers')
const configuration = require('@feathersjs/configuration')

const knex = require('../knex')
const createItemsTable = require('../services/database/items/items.model')

async function main() {
   const app = feathers()

   app.configure(configuration())
   app.configure(knex)

   // database   
   await createItemsTable(app, "items")

   process.exit(0)
}

main()

backend/src/app.js

...
const configuration = require('@feathersjs/configuration')
const knex = require('./knex')
const itemsService = require('./database/items/items.service.js')
...
app.configure(configuration())

app.configure(knex)

app.configure(itemsService)
...

Services ‘custom’

On peut aussi créer des services totalement personalisés, mais leur utilisation doit nécessairement passer par l’utilisation d’une ou plusieurs des 7 méthodes ‘create’, ‘get’, ‘find’, etc.

Exemple : ajout d’un service ‘custom’ d’envoi de mail

app.js

...
const emailService = require("./services/custom/email/emailService")
app.configure(emailService)
...

src/services/email/emailService.js

const nodemailer = require('nodemailer')

module.exports = function(app) {

    class MailService {

        create(data) {
            const mailConfig = {
               "host": "smtp.online.net",
               "port": "587",
               "secure": false,
               "auth": {
                  "user": "fullstack@shdl.fr",
                  "pass": process.env.SMTP_PASSWORD,
               },
               "name": "shdl.fr"
            }
            const transporter = nodemailer.createTransport(mailConfig)
            try {
               return transporter.sendMail({
                  from: data.from,
                  to: data.to,
                  subject: data.subject,
                  text: data.text,
                  html: data.html,
               })
            } catch(err) {
               console.log(err)
            }
        }
    }

    app.use("email", new MailService())

}

Client node : src/scripts/send-mail.js

// Usage:
// cd backend
// node src/scripts/send-email.js buisson@n7.fr Hello "Hello JC how are you doing?"

const io = require('socket.io-client')
const feathers = require('@feathersjs/feathers')
const socketio = require('@feathersjs/socketio-client')

const socket = io('http://localhost:3030')
const app = feathers()
app.configure(socketio(socket))

async function main() {
   await app.service('email').create({
      from: 'buisson@enseeiht.fr',
      to: process.argv[2],
      subject: process.argv[3],
      text: process.argv[4],
   })
   process.exit(0)
}

main()

Activité à réaliser

Créer un client web d’envoi de mail, avec l’interface suivante :

Hooks

Ce sont des méthodes qui peuvent être appelées avant ou après l’exécution d’une des méthodes d’un service :

  • vérifier que l’utilisateur est authentifié
  • vérifier que l’utilisateur a des privilèges suffisants
  • limiter les requêtes d’un utilisateur à ce qu’il est autorisé à accéder
  • valider le ‘payload’ d’une requête par rapport à un schema
  • adapter certains attributs du payload des requêtes (ajouter une date de modification, normaliser un numéro de téléphone)
  • retirer certains champs d’une réponse (ex: mot de passe)
  • déclencher des actions (ex: envoyer un email, créer une entrée d’historisation)

Exemple :

app.service('users').hooks({
   before: {
      patch: [async context => {
         context.data.updatedAt = new Date();
         return context;
      }],
      ...

Lorsqu’un hook produit une erreur, seuls les hooks d’erreur sont appelés. Les autres hooks et le service lui même (s’il n’a pas déjà été exécuté) ne sont pas appelés.

FeathersJS fournit de nombreux hooks prédéfinis qu’il suffit d’ajouter dans la bonne rubrique. Par exemple, des hooks d’authentification prédéfinis permettent de vérifier que l’utilisateur est authentifié (hook authenticate), de hasher un mot de pass (hook hashPassword), de supprimer un attribut/valeur d’un object résultat (hook protect) :

app.js

...
app.configure('./services/users/users.hooks')
...

users.hooks.js

const { authenticate } = require('@feathersjs/authentication').hooks
const { hashPassword, protect } = require('@feathersjs/authentication-local').hooks

module.exports = {
   before: {
      all: [],
      find: [authenticate('jwt')],
      get: [authenticate('jwt')],
      create: [hashPassword('password')],
      update: [hashPassword('password'), authenticate('jwt')],
      patch: [hashPassword('password'), authenticate('jwt')],
      remove: [authenticate('jwt')],
   },

   after: {
      all: [
         protect('password'),
      ],
      find: [],
      get: [],
      create: [],
      update: [],
      patch: [],
      remove: []
   },

   error: {
      all: [],
      find: [],
      get: [],
      create: [],
      update: [],
      patch: [],
      remove: []
   }
}

Problématique de l’authentification

Gestion des mots de passe

  • ils doivent être stockés de façon hashée dans la BD, jamais en clair
  • la production du hash est non-inversible, mais permet de vérifier la concordance
  • le hash doit être salé, sinon il peut être attaqué par des dictionnaires de valeurs hashées de mots de passe

Librairie bcrypt

$ npm install bcrypt

Production du hash

const bcrypt = require('bcrypt')
let saltRounds = 10
let hash = bcrypt.hashSync("eureka", saltRounds)

hash –> ‘$2b1010Mc07H0wBqBL9a6g32ecig.LbUbk2uBeCYIlDrcZ0Goe9lSLXVeTj2’

Le hash d’un mot de passe est modifié à chaque appel (salage), mais cela n’affecte pas la vérification de la compatibilité d’un mot de passe candidat avec l’une quelconque de ses valeurs de hashage

Vérification d’un mot de passe candidat

bcrypt.compareSync("eureka", hash) // true
bcrypt.compareSync("eurehack", hash) // false

Les tokens JWT

Voir https://jwt.io

Un token JWT est composé de trois parties, séparées par un point :

  • une en-tête qui indique notamment le type de signature
  • un payload json, non crypté. Sa validité est garantie par la signature
  • une signature

Après authentification d’un utilisateur, le serveur lui transmet un token (de session) que le client ajoutera à toutes ses requêtes. Typiquement, un client stocke ce token dans un cookie.
Le serveur n’accepte de traiter une requête que si elle contient un token dont la signature est valide.
Le payload du token contient typiquement l’id de l’utilisateur et une date d’expiration. Conrairement à d’autres types de tokens, le serveur n’a pas besoin de stocker les tokens JWT car ils contiennent dans leur payload les informations dont il a besoin.

Créer un token

let jwt = require('jsonwebtoken')
let token = jwt.sign({ foo: 'bar' }, "MYSECRET")

Lire le payload d’un token

let payload = jwt.decode(token)

Vérifier la signature d’un token

try {
   let payload = jwt.verify(token, "MYSECRET")
} catch(err) {
   console.log("signature invalide")
}

Authentification par cookies vs authentification par tokens

  • lors d’une authentification par cookies, un cookie d’authentification est envoyé par le backend vers le navigateur lors du login. Toute requête du frontend vers le backend est alors sécurisée, car elle contient automatiquement le cookie d’authentification.

  • si l’authentification est basée sur des tokens (ex: OAuth), un token d’accès est envoyé par le backend vers le frontend lors du login. Le frontend stocke ce token d’accès dans LocalStorage ou SessionStorage ou plus rarement dans un cookie, et toutes les requêtes vers le backend doivent comporter un header ‘Authorization: Bearer ’.

Authentification avec FeathersJS

FeathersJS contient des fonctions back et front d’authentification par token. Elles sont de deux sortes :

  • l’authentification dite ‘locale’, avec identifiant/mot de passe
  • l’authentification OAuth 2 avec tout type de serveur d’authentification (Google, Facebook, LinkedIn, etc.)

Authentification ‘locale’ avec FeathersJS

TODO

Authentification OAuth 2 avec FeathersJS

TODO

Activité à réaliser

  • Créer une application web avec un écran de login via Google

Projet à rendre

Spécification

Réaliser une application de saisie de notes :

  • une liste des notes existantes est toujours visible dans une colonne à gauche ; la sélection d’une note provoque son affichage dans la partie droite
  • un bouton ‘+’ en haut de la colonne permet de créer une nouvelle note et de passer en mode édition ; un bouton ‘-’ en face de chaque nom de note permet de l’effacer
  • les notes peuvent être éditées ; en mode édition une note est rédigée en markdown ; en mode lecture elle est affichée “décorée”
  • le nom d’une note tel qu’il apparait dans la colonne de gauche correspond à la première ligne du texte de la note
  • l’application sera déployée à l’url : http://notes-myname.dufullstack.fr

Modalités

Le travail sera rendu sous forme d’un projet Gitlab nommé fs-notes et par la résolution successive des issues suivantes :

  • “[back-end] Mise en place” : créer un répertoire backend/ qui contient une application Feathers minimale, et un répertoire frontend/ qui contient un client web minimal
  • “[back-end] Modèle de données” : créer dans backend/ les services de données nécessaires à l’application, et des tests Mocha de création / suppression / modification de notes
  • “[front-end] Editeur” : créer dans frontend/ un éditeur markdown par utilisation de la librairie marked
  • “[front-end] Application” : finaliser la partie front-end de l’application

[OPTIONNEL] Production de pages HTML côté serveur

Express permet de plugger des moteurs de template de façon unifiée et de produire des pages avec la fonction render.

app.set("views", path.join(__dirname, "views"))
app.set("view engine", "xxx")
...
app.get("/", (req, res) => {
   res.render("index", { title: "Home" })
})

pug est un des moteurs de template les plus populaire.

Exemple

server.js

const express = require('express')
const path = require('path')

const app = express()

app.set("views", path.join(__dirname, "views"))
app.set("view engine", "pug")

app.use(express.static(path.join(__dirname, "public")))

app.get("/", (req, res) => {
   res.render("index", {
      name: "Chris",
      url: "https://wiki.formation-fullstack.fr",
   })
})

app.listen(3000, function () {
  console.log("Server listening on port 3000")
})

views/index.pug

doctype html
html
   head
      title Hello #{name}
      link(rel="stylesheet" href="/style.css")

   body
      p.greetings#guy Hello #{name}!

      p.plain
         | Pour un text multi-ligne,
         | il est préférable d'utiliser cette écriture à base de 'pipe'

      a(href = url) Lien vers la formation Fullstack

public/style.css

.greetings {
   color: green;
   text-transform: uppercase;
   font-size: 2em;
}

.plain {
   color: blue;
}

Le html calculé est :

<!DOCTYPE html>
<html>
   <head>
      <title>Hello Chris</title>
      <link rel="stylesheet" href="/style.css">
   </head>
   <body>
      <p class="greetings" id="guy">Hello Chris!</p>
      <a href="https://wiki.formation-fullstack.fr">Lien vers la formation Fullstack</a>
   </body>
</html>