Enkripsi Websocket Dengan Express JS Dan Socketio - CRUDPRO

Enkripsi Websocket Dengan Express JS Dan Socketio

Anda baru saja membuat proyek Express baru yang siap mengembangkan API Anda berikutnya — yang kebetulan memerlukan koneksi WebSocket yang aman.

# firing up yet another Express boilerplate with express-generator
express secure-websocket

Bagian ini dimulai dari sini— boilerplate Express baru — dan memandu Anda melalui proses mengonfigurasi Websocket yang aman, dari proxy terbalik Nginx terenkripsi ke layanan Socket.io terenkripsi yang berjalan di PM2.

Lebih khusus lagi, kami akan membahas:

  • Cara membuat sertifikat SSL yang ditandatangani sendiri untuk lingkungan pengembangan anda untuk meniru koneksi terenkripsi dari lingkungan produksi anda
  • Membuat kelas SocketService ES6 untuk mengelola proses inisialisasi server Websocket
  • Menjalankan server Express kami dengan manajer proses PM2, dan mengonfigurasi Nginx ke proxy permintaan aman untuk itu

Semuanya harus dienkripsi melalui halaman web HTTPS, termasuk koneksi Websocket. Peramban utama seperti Safari memblokir koneksi Websocket yang tidak aman pada halaman web terenkripsi (ini adalah skenario "konten campuran" di mana halaman web terenkripsi mencoba terhubung ke layanan yang tidak terenkripsi). Bagian ini berfokus pada solusi untuk masalah ini sehingga tidak akan pernah berlaku untuk aplikasi Anda.

Satu-satunya ketergantungan lain yang kita perlukan agar ini berfungsi adalah socket-io:

# install socket.io
cd secure-websocket && yarn add socket.io

Sekarang, prioritas pertama kami untuk pengaturan seperti itu adalah memiliki sertifikat SSL untuk melayani Websocket. Untuk server produksi Anda, kemungkinan besar Anda akan membeli sertifikat dari otoritas tepercaya, tetapi untuk tujuan pengembangan, kami memerlukan sertifikat yang ditandatangani sendiri untuk mengenkripsi localhost.

Sebelum masuk ke beberapa kode, bagian berikut akan membahas proses pembuatan sertifikat yang ditandatangani sendiri untuk lingkungan pengembangan Anda dengan openssl.

Menghasilkan Sertifikat yang Ditandatangani Sendiri untuk Pengembangan

Untuk tujuan pengembangan lokal, kami akan membuat sertifikat SSL yang ditandatangani sendiri untuk digunakan dengan WebSocket terenkripsi. Cara paling sederhana untuk menyimpan sertifikat ini adalah dengan memiliki direktori ssl/ di folder proyek Anda yang dihilangkan dari kontrol sumber dalam file .gitignore Anda:

// .gitignore
# development /ssl

Direktori berada di folder proyek Anda yang menghosting file .crt dan .key:

/secure-websocket
/ssl
server.crt
server.key
...

Dua file yang di-bold adalah file sertifikat yang ingin kami buat dengan openssl. Mereka kemudian akan digunakan saat menginisialisasi server soket kami dalam mode pengembangan.

Menghasilkan sertifikat

Siapa pun dapat membuat sertifikat mereka sendiri tanpa bantuan dari CA (Certificate Authority). Satu-satunya perbedaan adalah bahwa sertifikat yang Anda buat sendiri, dibandingkan dengan membelinya dari pihak tepercaya, tidak akan dipercaya oleh orang lain.

Untuk pengembangan lokal, ini baik-baik saja — kami hanya ingin sertifikat ini meniru aliran data terenkripsi ke dan dari Websocket kami.

Sebagai alternatif untuk membeli sertifikat SSL, kami memiliki Certbot, alat sumber terbuka dan gratis untuk menggunakan sertifikat Let's Encrypt secara otomatis. Google Chrome bahkan merupakan "sponsor platinum" dari Let's Encrypt, jadi kecil kemungkinan Chrome tidak akan mempercayainya di masa mendatang. Namun, perlu diingat bahwa Let's Encrypt tidak memiliki dukungan bilah hijau — mereka berhenti pada validasi tingkat domain.

Cara paling sederhana untuk menghasilkan kunci pribadi dan sertifikat yang ditandatangani sendiri untuk localhost adalah dengan satu perintah openssl. Buat dan lompat ke folder /ssl di direktori proyek Anda untuk membuatnya:

/# jump into ssl folder
mkdir ssl && cd ssl# generate certificate for localhostopenssl req -x509 \
  -out localhost.crt \
  -keyout localhost.key \
  -newkey rsa:2048 -nodes -sha256 \
  -subj '/CN=localhost' \
  -extensions EXT -config <( \
   printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")

Jalankan di atas dan Anda akan menerima output berikut di Terminal Anda, memverifikasi bahwa kunci dan sertifikat berhasil dibuat:

/Generating a 2048 bit RSA private key
..........+++
................................+++
writing new private key to 'localhost.key'
-----

Mempercayai sertifikat (di macOS)

Sekarang ada beberapa langkah penting yang harus diambil sebelum sertifikat dipercaya.

Bagian ini didasarkan pada pengembangan macOS, tetapi konsep yang sama berlaku untuk sistem lain — sistem harus memercayai sertifikat.

Ikuti petunjuk berikut untuk memercayai sertifikat dengan Akses Rantai Kunci:

  • Klik dua kali localhost.crt untuk membukanya di Akses Rantai Kunci.
  • Klik dua kali sertifikat localhost di daftar Sertifikat Anda
  • Di bawah bagian Kepercayaan, alihkan ke always trust

Tangkapan layar berikut mengilustrasikan proses ini:

Sekarang restart mesin Anda

Ada satu langkah terakhir sebelum perubahan ini berlaku: restart mesin Anda. Gagal melakukannya akan mengakibatkan sertifikat Anda menjadi tidak valid di browser karena otoritas sertifikat yang tidak tepercaya.

Dengan sertifikat kami sekarang diatur, kami sekarang dapat melanjutkan dan membuat kelas SocketService kami, yang akan menangani koneksi socket.io kami. Kami juga akan menjelajahi beberapa tips di sepanjang jalan untuk mengelola kode sambil membuatnya ramah multi-lingkungan.

Kembali ke Javascript: Kelas SocketService

Kami akan mendefinisikan kelas SocketService untuk mengelola koneksi Websocket kami di bagian ini. Kelas SocketService lengkap disediakan di akhir artikel sebagai Github Gist.

Ringkasan Layanan Socket

SocketService menggunakan app ekspres Anda dan nomor port sebagai argumen konstruktornya — ini hanya dua bagian data yang kita perlukan untuk mengaktifkan dan menjalankan Websocket. Di dalam konstruktornya, SocketService mendeklarasikan server HTTPS (tempat sertifikat SSL kami ikut bermain) dan menyimpan objek yang dihasilkan sebagai properti kelas: this.server.

Dari sini kita cukup membuat instance SocketService baru dan menginisialisasi server socket dengan metode terpisah, initServer(), di app.js:

/// app.js
var SocketService = require('./SocketService');
...
var socket = new SocketService(app, 3003);
socket.initServer();

Saya telah menggunakan deklarasi variabel var di sini bertepatan dengan kode boilerplate Express, tetapi const juga dapat digunakan di sini.

Jika Anda bertanya-tanya seperti apa initServer() pada saat ini, itu hanya membuat socket.io dan mendengarkan di port yang kami berikan ke konstruktor:

/// SocketService.js initServer() methodinitServer () {
   this.io = require('socket.io')(this.server);
   this.server.listen(this.port);
}

Seperti yang Anda lihat, kami menyimpan instance socket.io di properti kelas lain, this.io. Dari sini kita dapat merujuk ke properti kelas ini untuk mendefinisikan peristiwa io, semuanya berlanjut dari dalam app.js:

/// app.js continued
socket.io.on('connection', socket => {
... }

Saya telah memisahkan instantiasi soket dan metode initServer() untuk memisahkan koneksi WebSocket dari instantiasi kelas — ini adalah praktik yang baik karena membuat logika Anda lebih mudah dibaca dan fleksibel. Mengapa? Karena Dalam beberapa kasus, Anda tidak ingin Websocket langsung diinisialisasi — kami mungkin ingin menginisialisasi server soket saat kondisi tertentu terpenuhi. constructor() berguna, tetapi kami tidak ingin melakukan terlalu banyak:

// Pada tahap ini, Websocket kami dikonfigurasi
var socket = new SocketService(app, 3003);
// pengaturan dan inisialisasi lebih lanjut...
// Sekarang ketika kita siap, inisialisasi socket.io dan dengarkan di port
socket.initServer();

Ini adalah solusi yang lebih fleksibel, mengikuti pendekatan berorientasi objek untuk mengelola koneksi soket. Mari selami modul dan konstruktor SocketService selanjutnya.

Modul dan konstruktor SocketService

SocketService hanyalah kelas ES6, di mana kita mendefinisikan dependensinya di bagian atas, mendefinisikan kelas, dan kemudian menyediakannya sebagai satu-satunya ekspor modul:

// import dependencies
var fs = require('fs');
var https = require('https');
var path = require('path');// define the class
class SocketService {
   constructor(app, port) {
      ... 
   }   initServer() { 
      ...
   }
}// export the class
module.exports = SocketService;

Jika Anda mendefinisikan beberapa kelas dalam satu modul, module.exports Anda dapat diperluas menjadi objek kelas.

constructor() pertama-tama menetapkan port yang kami sediakan sebagai properti kelas, kemudian memperkenalkan pernyataan switch berdasarkan process.env.NODE_ENV — variabel lingkungan yang disediakan oleh NodeJS untuk menentukan lingkungan tempat kita berada. Pernyataan switch ini kemudian digunakan untuk konfigurasikan server HTTPS yang berbeda berdasarkan lingkungan kita:

constructor (app, port) {
   this.port = port;   switch (process.env.NODE_ENV) {
      case 'development':
        // define development https server
      break;      default:
        //define production https server
   }
}

Kami memperlakukan kasus default sebagai server produksi kami di sini. Sintaks ini cukup fleksibel karena Anda bisa memasukkan kasus staging di sana juga jika aplikasi Anda memerlukannya, mendefinisikan server HTTPS khusus untuk setiap lingkungan Anda.

Untuk bereksperimen dengan lebih banyak nilai NODE_ENV, cukup berikan variabel lingkungan saat menjalankan express:

# providing NODE_ENV when starting your express server
NODE_ENV=production yarn start

Kami juga telah menggunakan variabel lingkungan dalam pernyataan peralihan kami untuk membantu dalam sertifikat SSL kami. Mari kita lihat bagaimana kita mendefinisikan server pengembangan terlebih dahulu:

case 'development':
   this.server =
      https.createServer({
         key: fs.readFileSync(
           path.resolve(
              process.env.SSL_DEV_KEY || './ssl/localhost.key'
           )
         ),
         cert: fs.readFileSync(
            path.resolve(
               process.env.SSL_DEV_CRT || './ssl/localhost.crt'
            )
         )
      }, app);
break;

Cukup banyak yang terjadi di blok ini, tetapi kami hanya mengonfigurasi server HTTPS berkat modul https yang kami impor sebelumnya, menyimpan hasilnya sebagai properti kelas server. Mari kita uraikan ini lebih lanjut:

  • https.createServer() (dokumentasi di sini) menyediakan bidang kunci dan sertifikat bagi kita untuk memasukkan sertifikat kita. Seperti yang akan kita lihat lebih jauh, kita juga dapat menggunakan bidang ca untuk file ca-bundle sertifikat. Untuk tujuan pengembangan, ini tidak diperlukan.
  • Kami menggunakan modul fs yang diimpor untuk mengembalikan konten jalur file, menggunakan fs.readFileSync(). Namun, jalur file yang kami sediakan adalah jalur file relatif, bukan absolut — kami tahu jalur tersebut ada di folder ssl/ kami yang berada di direktori yang sama dengan file saat ini, itulah sebabnya kami menggunakan jalur relatif ./ssl/ <nama_of_cert>.
  • Untuk mengakomodasi jalur relatif ini, kami telah menggunakan path.resolve() dari modul jalur yang kami impor. Sekarang sertifikat kami akan ditemukan dan diselesaikan oleh fs.readFileSync() tanpa masalah.
  • Kami sebenarnya memiliki pernyataan bersyarat di dalam path.resolve() — pertama-tama kami memeriksa apakah ada variabel lingkungan — masing-masing SSL_DEV_KEY dan SSL_DEV_CRT — memberi kami opsi untuk menyediakan jalur khusus saat kami memutar server. Jika variabel lingkungan ini tidak ada, kita kembali ke string yang disediakan:
// environment variable if provided or default string fallback value
path.resolve( process.env.SSL_DEV_KEY || './ssl/localhost.key' );

jika tim pengembang berdiskusi dan tiba-tiba memutuskan untuk memindahkan direktori ssl/ ke lokasi baru, maka kita dapat menggunakan variabel lingkungan ini dan cukup memulai ulang proses server agar perubahan diterapkan:

SSL_DEV_KEY=./new/location/localhost.key yarn start

Sekarang, mari kita periksa server produksi dalam kasus default, yang menggunakan pengaturan serupa:

// production server - default casedefault:
   this.server = https.createServer({
      key: fs.readFileSync(
         process.env.SSL_PDT_KEY || '/etc/nginx/ssl/domain.key'
      ),
      cert: fs.readFileSync(
         process.env.SSL_PDT_CRT || '/etc/nginx/ssl/domain.crt'
      ),
      ca: fs.readFileSync(
         process.env.SSL_PDT_CA || '/etc/nginx/ssl/domain.ca-bundle'
      ),
      requestCert: true,
      rejectUnauthorized: false
   }, app);

Kali ini kami telah menggunakan jalur file absolut, menghilangkan path.resolve() dari lokasi file kami. Ini lebih masuk akal jika Anda menggunakan sertifikat SSL produksi yang mungkin disimpan di tempat lain di sistem file Anda, seperti di direktori Nginx ssl/. Bidang ca https.createServer() juga telah disediakan untuk file ca-bundle.

Juga termasuk bidang requestCert, disetel ke true, memastikan bahwa otentikasi sertifikat digunakan. rejectUnauthorized juga telah disetel ke false, karena kesalahan yang dihasilkan dari kegagalan otorisasi dapat menjadi penghalang yang membuat frustrasi untuk menghubungkan jika Anda tidak dapat menemukan kesalahan apa pun dalam pengaturan Anda. Ubah ini menjadi true setelah Anda memverifikasi bahwa semuanya berfungsi tanpa pemeriksaan tambahan ini.

Jika rejectUnauthorized disetel ke true (yang juga merupakan nilai default), sertifikat server diverifikasi terhadap daftar CA yang disediakan. Peristiwa kesalahan akan muncul jika verifikasi ini gagal. Verifikasi terjadi sebelum permintaan HTTP dikirim, pada tingkat koneksi.

Itu yang dicakup oleh konstruktor SocketService. Sekarang melihat initServer() lagi, jelas bagaimana kita mengambil nilai this.server dan this.port yang sudah dikonfigurasi untuk menginisialisasi socket.io:

initServer () {
   this.io = require('socket.io')(this.server);
   this.server.listen(this.port);
 }
Membuat Instansi SocketService di app.js

Kami secara singkat menyentuh bagaimana instantiasi SocketService terjadi di app.js, tetapi mari kita perluas ini untuk memiliki output console.log() ketika klien terhubung dan terputus ke layanan.

Demi kesederhanaan, mari kita tempatkan WebSocket kita setelah aplikasi dikonfigurasi, sebelum akhir module.exports = app; line:

// app.js
var SocketService = require('./SocketService');
...
var mySocket = new SocketService(app, 3003);
mySocket.initServer();
mySocket.io.on('connection', socket => {
console.log('client connected');
// define more events here...
socket.on('disconnect', reason => { console.log(client disconnected); console.log(reason); }); }module.exports = app;
// end of file

Dengan ini, kami siap menjalankan server. Untuk melakukannya, manajer proses PM2 akan digunakan.

Menjalankan Ekspres dengan PM2

Cukup mudah untuk mulai menggunakan PM2, baik secara lokal maupun di server produksi Anda. Jika Anda belum melakukannya, instal PM2 secara global:

yarn global add pm2

Di dalam direktori proyek ekspres Anda, cara tercepat untuk memulai server Anda sebagai proses PM2 adalah dengan menjalankan perintah start dengan flag --watch — yang secara otomatis memulai ulang proses ketika perubahan dibuat pada kode sumber:

PORT=3001 pm2 start bin/www --name 'secure-websocket' --watch

Perhatikan bahwa kami telah menentukan port untuk server yang akan dijalankan sebelum menginisialisasi proses PM2. Ini akan menjadi penting ketika kita merutekan permintaan Nginx ke server.

Menggunakan File Ekosistem PM2 sebagai gantinya

Namun ada cara yang lebih canggih untuk memulai proses PM2 — apa yang disebut PM2 sebagai "File Ekosistem", yang kami rujuk saat memulai proses.

File ekosistem dapat mengonfigurasi satu atau beberapa aplikasi, atau proses, untuk dijalankan secara bersamaan. Untuk kasus penggunaan kami, kami dapat menggunakan file ekosistem untuk mengonfigurasi variabel lingkungan yang kami definisikan sebelumnya. Buat file ekosistem.config.js berikut dalam direktori proyek Anda:

module.exports = {
  apps: [
    {
      name: 'secure-websocket',
      cwd: '~/www/secure-websocket',
      script: 'npm',
      args: 'start',
      env: {
        PORT: 3001,
        NODE_ENV: 'production',
        SSL_DEV_KEY: './ssl/server.key',
        SSL_DEV_CRT: './ssl/server.crt',
        SSL_PDT_KEY: '/etc/nginx/ssl/domain.key',
        SSL_PDT_CRT: '/etc/nginx/ssl/domain.crt',
        SSL_PDT_CA: '/etc/nginx/ssl/domain.ca-bundle',
      },
    },
  ],
};

Sekarang, seperti yang Anda lihat, kita dapat mendefinisikan variabel lingkungan dengan cara yang dapat dikelola dari dalam bidang env. Kami juga telah memberikan data lain seperti port, nama proses, dan perintah mulai, semua dalam file ini.

Sekarang kita dapat menjalankan proses PM2 dari dalam direktori proyek hanya dengan merujuk ke file ekosistem ini:

pm2 start ecosystem.config.js --watch

Bergantung pada sensitivitas data yang disediakan dalam file ini, Anda mungkin ingin menambahkan semua instance ekosistem.config.jske .gitignore. Variabel lingkungan dalam lingkungan Node JS sering digunakan untuk menyimpan nilai sensitif seperti kunci API.

Bagian terakhir dari teka-teki ini adalah mengarahkan lalu lintas melalui Proxy Terbalik Nginx untuk server produksi Anda. Mari kita bahas ini sekarang.

Proxy Terbalik Websocket Nginx

Menyiapkan pass proxy untuk WebSocket dilakukan dengan cara yang sangat mirip dengan layanan API standar. Konfigurasi bare-bones berikut mendengarkan port 443, menjadi saluran terenkripsi kami, dan meneruskan permintaan ke proses ekspres PM2 kami yang berjalan di localhost:3001.

Tambahkan konfigurasi ini ke direktori /etc/nginx/conf.d Anda, ubah nilai yang dicetak tebal menjadi nilai Anda sendiri:

# /etc/nginx/conf.d/domain.com.confserver {
  listen 443;
  server_name domain.com www.domain.com;  ssl on;
  ssl_certificate /etc/nginx/ssl/domain.crt;
  ssl_certificate_key /etc/nginx/ssl/domain.key;
  ssl_session_cache shared:SSL:1m;
  ssl_session_timeout  10m;
  ssl_ciphers HIGH:!aNULL:!MD5;
  ssl_prefer_server_ciphers on;  resolver 127.0.0.1;
  ssl_stapling on;
  ssl_stapling_verify on;
  ssl_trusted_certificate /etc/nginx/ssl/domain.ca-bundle;  location / {
    proxy_set_header   X-Forwarded-For $remote_addr;
    proxy_set_header   Host $http_host;
    proxy_pass         "https://localhost:3001";
    proxy_http_version 1.1;
    proxy_set_header   Upgrade $http_upgrade;
    proxy_set_header   Connection "upgrade";
  }
}

Pengaturan ini menyediakan saluran data terenkripsi, dengan permintaan terenkripsi masuk ke server kami dan dipenuhi oleh Nginx, sebelum diproksi ke layanan WebSocket kami yang aman. Karena kami mem-proxy permintaan secara internal, tidak perlu membuka port 3001, atau port mana pun yang Anda gunakan, dari firewall Anda.

Cara otentikasi lainnya dapat dilakukan pada lapisan Nignx, seperti menguji header permintaan untuk memastikan bahwa mereka berasal dari pengirim yang dikenal. Tentu saja, ini juga dapat diuji pada lapisan Node juga.

Dengan file konfigurasi ini, lanjutkan dan mulai ulang layanan Nginx Anda agar server ditambahkan ke konfigurasi Anda:

sudo service nginx restart
Menghubungkan dengan aman dari frontend

Terakhir, ingatlah untuk terhubung dengan aman ke Websocket dari aplikasi frontend Anda. Misalnya, menggunakan paket socket.io-client dalam aplikasi Javascript:

// connecting to a secure websocket from the frontend
import io from 'socket.io-client';
const socket = io.connect('https://localhost:3001/', { transports: ['websocket'], secure: true });

Minta URL layanan Anda dimulai dengan https dan atur konfigurasi aman ke true.

Ringkasan

Bagian ini telah membahas pengaturan koneksi WebSocket yang aman menggunakan Express dan Socket.io dengan PM2 dan Nginx.

Untuk membantu menerapkan solusi Websocket yang aman ke kasus penggunaan dunia nyata, saya menerbitkan sebuah artikel tentang membangun SVG grafik harga yang ditujukan untuk data harga waktu nyata. Sebagai tantangan yang menyenangkan, dapatkah Anda menautkan data SVG dengan koneksi soket web langsung?

Berikut adalah Intisari lengkap dari SocketService.js — ini dapat bertindak sebagai dasar yang baik untuk layanan Anda sendiri:

var fs = require('fs');
var https = require('https');
var path = require('path');

class SocketService {

  constructor (app, port) {
    this.port = port;

    switch (process.env.NODE_ENV) {

      case 'development':
        this.server =
          https.createServer({
            key: fs.readFileSync(
              path.resolve(process.env.SSL_DEV_KEY || './ssl/localhost.key')
            ),
            cert: fs.readFileSync(
              path.resolve(process.env.SSL_DEV_CRT || './ssl/localhost.crt')
            )
          }, app);
        break;

      default:
        this.server = https.createServer({
          key: fs.readFileSync(
            process.env.SSL_PDT_KEY || '/etc/nginx/ssl/domain.key'
          ),
          cert: fs.readFileSync(
            process.env.SSL_PDT_CRT || '/etc/nginx/ssl/domain.crt'
          ),
          ca: fs.readFileSync(
            process.env.SSL_PDT_CA || '/etc/nginx/ssl/domain.ca-bundle'
          ),
          requestCert: true,
          rejectUnauthorized: false
        }, app);
    }
  }

  initServer () {
    this.io = require('socket.io')(this.server);
    this.server.listen(this.port);
  }
}

module.exports = SocketService;